Преглед на файлове

feat(core)!: Make Asset entity translatable (#4171)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Will Nahmens преди 1 ден
родител
ревизия
dbec6de7c9
променени са 45 файла, в които са добавени 641 реда и са изтрити 82 реда
  1. 21 0
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 3 0
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  3. 0 2
      packages/asset-server-plugin/e2e/graphql/graphql-env-admin.d.ts
  4. 0 1
      packages/asset-server-plugin/e2e/graphql/graphql-env-shop.d.ts
  5. 11 0
      packages/common/src/generated-shop-types.ts
  6. 21 0
      packages/common/src/generated-types.ts
  7. 204 0
      packages/core/e2e/asset-custom-fields.e2e-spec.ts
  8. 1 0
      packages/core/e2e/custom-field-relations.e2e-spec.ts
  9. 2 0
      packages/core/e2e/custom-fields.e2e-spec.ts
  10. 1 0
      packages/core/e2e/fixtures/test-plugins/hydration-test-plugin.ts
  11. 0 2
      packages/core/e2e/graphql/graphql-env-admin.d.ts
  12. 0 1
      packages/core/e2e/graphql/graphql-env-shop.d.ts
  13. 2 2
      packages/core/e2e/order-multi-vendor.e2e-spec.ts
  14. 4 4
      packages/core/src/api/resolvers/admin/asset.resolver.ts
  15. 29 1
      packages/core/src/api/resolvers/entity/asset-entity.resolver.ts
  16. 8 0
      packages/core/src/api/schema/admin-api/asset.api.graphql
  17. 10 0
      packages/core/src/api/schema/common/asset.type.graphql
  18. 2 0
      packages/core/src/common/types/locale-types.ts
  19. 1 1
      packages/core/src/data-import/providers/importer/importer.ts
  20. 28 0
      packages/core/src/entity/asset/asset-translation.entity.ts
  21. 9 3
      packages/core/src/entity/asset/asset.entity.ts
  22. 1 0
      packages/core/src/entity/custom-entity-fields.ts
  23. 2 0
      packages/core/src/entity/entities.ts
  24. 1 0
      packages/core/src/entity/index.ts
  25. 40 2
      packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts
  26. 3 2
      packages/core/src/service/helpers/translatable-saver/translation-differ.ts
  27. 18 2
      packages/core/src/service/helpers/utils/translate-entity.ts
  28. 99 22
      packages/core/src/service/services/asset.service.ts
  29. 3 3
      packages/dashboard/src/app/routeTree.gen.ts
  30. 14 6
      packages/dashboard/src/app/routes/_authenticated/_assets/assets.graphql.ts
  31. 27 2
      packages/dashboard/src/app/routes/_authenticated/_assets/assets_.$id.tsx
  32. 6 2
      packages/dashboard/src/app/routes/_authenticated/_products/components/create-product-variants-dialog.tsx
  33. 0 4
      packages/dashboard/src/lib/components/shared/asset/asset-properties.tsx
  34. 42 5
      packages/dashboard/src/lib/components/shared/translatable-form-field.tsx
  35. 6 0
      packages/dashboard/src/lib/graphql/fragments.ts
  36. 0 2
      packages/dashboard/src/lib/graphql/graphql-env.d.ts
  37. 3 5
      packages/dev-server/graphql/graphql-env.d.ts
  38. 8 2
      packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts
  39. 0 2
      packages/elasticsearch-plugin/e2e/graphql/graphql-env-admin.d.ts
  40. 0 1
      packages/elasticsearch-plugin/e2e/graphql/graphql-env-shop.d.ts
  41. 0 2
      packages/payments-plugin/e2e/graphql/graphql-env-admin.d.ts
  42. 0 1
      packages/payments-plugin/e2e/graphql/graphql-env-shop.d.ts
  43. 11 0
      packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts
  44. 0 0
      schema-admin.json
  45. 0 0
      schema-shop.json

+ 21 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -256,11 +256,13 @@ export type Asset = Node & {
   focalPoint?: Maybe<Coordinate>;
   height: Scalars['Int']['output'];
   id: Scalars['ID']['output'];
+  languageCode: LanguageCode;
   mimeType: Scalars['String']['output'];
   name: Scalars['String']['output'];
   preview: Scalars['String']['output'];
   source: Scalars['String']['output'];
   tags: Array<Tag>;
+  translations: Array<AssetTranslation>;
   type: AssetType;
   updatedAt: Scalars['DateTime']['output'];
   width: Scalars['Int']['output'];
@@ -273,6 +275,7 @@ export type AssetFilterParameter = {
   fileSize?: InputMaybe<NumberOperators>;
   height?: InputMaybe<NumberOperators>;
   id?: InputMaybe<IdOperators>;
+  languageCode?: InputMaybe<StringOperators>;
   mimeType?: InputMaybe<StringOperators>;
   name?: InputMaybe<StringOperators>;
   preview?: InputMaybe<StringOperators>;
@@ -316,6 +319,22 @@ export type AssetSortParameter = {
   width?: InputMaybe<SortOrder>;
 };
 
+export type AssetTranslation = {
+  __typename?: 'AssetTranslation';
+  createdAt: Scalars['DateTime']['output'];
+  id: Scalars['ID']['output'];
+  languageCode: LanguageCode;
+  name: Scalars['String']['output'];
+  updatedAt: Scalars['DateTime']['output'];
+};
+
+export type AssetTranslationInput = {
+  customFields?: InputMaybe<Scalars['JSON']['input']>;
+  id?: InputMaybe<Scalars['ID']['input']>;
+  languageCode: LanguageCode;
+  name?: InputMaybe<Scalars['String']['input']>;
+};
+
 export enum AssetType {
   BINARY = 'BINARY',
   IMAGE = 'IMAGE',
@@ -863,6 +882,7 @@ export type CreateAssetInput = {
   customFields?: InputMaybe<Scalars['JSON']['input']>;
   file: Scalars['Upload']['input'];
   tags?: InputMaybe<Array<Scalars['String']['input']>>;
+  translations?: InputMaybe<Array<AssetTranslationInput>>;
 };
 
 export type CreateAssetResult = Asset | MimeTypeError;
@@ -6891,6 +6911,7 @@ export type UpdateAssetInput = {
   id: Scalars['ID']['input'];
   name?: InputMaybe<Scalars['String']['input']>;
   tags?: InputMaybe<Array<Scalars['String']['input']>>;
+  translations?: InputMaybe<Array<AssetTranslationInput>>;
 };
 
 export type UpdateChannelInput = {

+ 3 - 0
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -128,6 +128,8 @@ const result: PossibleTypesResultData = {
             'Address',
             'Administrator',
             'Allocation',
+            'ApiKey',
+            'ApiKeyTranslation',
             'Asset',
             'AuthenticationMethod',
             'Cancellation',
@@ -171,6 +173,7 @@ const result: PossibleTypesResultData = {
         ],
         PaginatedList: [
             'AdministratorList',
+            'ApiKeyList',
             'AssetList',
             'ChannelList',
             'CollectionList',

Файловите разлики са ограничени, защото са твърде много
+ 0 - 2
packages/asset-server-plugin/e2e/graphql/graphql-env-admin.d.ts


Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
packages/asset-server-plugin/e2e/graphql/graphql-env-shop.d.ts


+ 11 - 0
packages/common/src/generated-shop-types.ts

@@ -92,11 +92,13 @@ export type Asset = Node & {
     focalPoint?: Maybe<Coordinate>;
     height: Scalars['Int']['output'];
     id: Scalars['ID']['output'];
+    languageCode: LanguageCode;
     mimeType: Scalars['String']['output'];
     name: Scalars['String']['output'];
     preview: Scalars['String']['output'];
     source: Scalars['String']['output'];
     tags: Array<Tag>;
+    translations: Array<AssetTranslation>;
     type: AssetType;
     updatedAt: Scalars['DateTime']['output'];
     width: Scalars['Int']['output'];
@@ -108,6 +110,15 @@ export type AssetList = PaginatedList & {
     totalItems: Scalars['Int']['output'];
 };
 
+export type AssetTranslation = {
+    __typename?: 'AssetTranslation';
+    createdAt: Scalars['DateTime']['output'];
+    id: Scalars['ID']['output'];
+    languageCode: LanguageCode;
+    name: Scalars['String']['output'];
+    updatedAt: Scalars['DateTime']['output'];
+};
+
 export enum AssetType {
     BINARY = 'BINARY',
     IMAGE = 'IMAGE',

+ 21 - 0
packages/common/src/generated-types.ts

@@ -255,11 +255,13 @@ export type Asset = Node & {
   focalPoint?: Maybe<Coordinate>;
   height: Scalars['Int']['output'];
   id: Scalars['ID']['output'];
+  languageCode: LanguageCode;
   mimeType: Scalars['String']['output'];
   name: Scalars['String']['output'];
   preview: Scalars['String']['output'];
   source: Scalars['String']['output'];
   tags: Array<Tag>;
+  translations: Array<AssetTranslation>;
   type: AssetType;
   updatedAt: Scalars['DateTime']['output'];
   width: Scalars['Int']['output'];
@@ -272,6 +274,7 @@ export type AssetFilterParameter = {
   fileSize?: InputMaybe<NumberOperators>;
   height?: InputMaybe<NumberOperators>;
   id?: InputMaybe<IdOperators>;
+  languageCode?: InputMaybe<StringOperators>;
   mimeType?: InputMaybe<StringOperators>;
   name?: InputMaybe<StringOperators>;
   preview?: InputMaybe<StringOperators>;
@@ -315,6 +318,22 @@ export type AssetSortParameter = {
   width?: InputMaybe<SortOrder>;
 };
 
+export type AssetTranslation = {
+  __typename?: 'AssetTranslation';
+  createdAt: Scalars['DateTime']['output'];
+  id: Scalars['ID']['output'];
+  languageCode: LanguageCode;
+  name: Scalars['String']['output'];
+  updatedAt: Scalars['DateTime']['output'];
+};
+
+export type AssetTranslationInput = {
+  customFields?: InputMaybe<Scalars['JSON']['input']>;
+  id?: InputMaybe<Scalars['ID']['input']>;
+  languageCode: LanguageCode;
+  name?: InputMaybe<Scalars['String']['input']>;
+};
+
 export enum AssetType {
   BINARY = 'BINARY',
   IMAGE = 'IMAGE',
@@ -862,6 +881,7 @@ export type CreateAssetInput = {
   customFields?: InputMaybe<Scalars['JSON']['input']>;
   file: Scalars['Upload']['input'];
   tags?: InputMaybe<Array<Scalars['String']['input']>>;
+  translations?: InputMaybe<Array<AssetTranslationInput>>;
 };
 
 export type CreateAssetResult = Asset | MimeTypeError;
@@ -6807,6 +6827,7 @@ export type UpdateAssetInput = {
   id: Scalars['ID']['input'];
   name?: InputMaybe<Scalars['String']['input']>;
   tags?: InputMaybe<Array<Scalars['String']['input']>>;
+  translations?: InputMaybe<Array<AssetTranslationInput>>;
 };
 
 export type UpdateChannelInput = {

+ 204 - 0
packages/core/e2e/asset-custom-fields.e2e-spec.ts

@@ -0,0 +1,204 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { mergeConfig } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
+import path from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import { graphql } from './graphql/graphql-admin';
+
+describe('Asset with translatable custom fields', () => {
+    const { server, adminClient } = createTestEnvironment(
+        mergeConfig(testConfig(), {
+            customFields: {
+                Asset: [
+                    { name: 'alt', type: 'localeString' as const },
+                    { name: 'title', type: 'localeString' as const },
+                ],
+            },
+        }),
+    );
+
+    let assetId: string;
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('creates an asset with translatable custom fields', async () => {
+        const filesToUpload = [path.join(__dirname, 'fixtures/assets/pps1.jpg')];
+        const { createAssets } = await adminClient.fileUploadMutation({
+            mutation: createAssetsWithCustomFieldsDocument,
+            filePaths: filesToUpload,
+            mapVariables: filePaths => ({
+                input: filePaths.map(p => ({
+                    file: null,
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            name: 'pps1.jpg',
+                            customFields: {
+                                alt: 'Default alt text',
+                                title: 'Default title',
+                            },
+                        },
+                    ],
+                })),
+            }),
+        });
+
+        expect(createAssets.length).toBe(1);
+        const asset = createAssets[0];
+        expect(asset).toHaveProperty('name', 'pps1.jpg');
+        expect(asset).toHaveProperty('customFields');
+        expect(asset.customFields.alt).toBe('Default alt text');
+        expect(asset.customFields.title).toBe('Default title');
+
+        assetId = asset.id;
+    });
+
+    it('updates asset with English translations', async () => {
+        const { updateAsset } = await adminClient.query(updateAssetWithCustomFieldsDocument, {
+            input: {
+                id: assetId,
+                translations: [
+                    {
+                        languageCode: LanguageCode.en,
+                        customFields: {
+                            alt: 'English alt text',
+                            title: 'English title',
+                        },
+                    },
+                ],
+            },
+        });
+
+        expect(updateAsset.customFields.alt).toBe('English alt text');
+        expect(updateAsset.customFields.title).toBe('English title');
+    });
+
+    it('updates asset with German translations', async () => {
+        const { updateAsset } = await adminClient.query(
+            updateAssetWithCustomFieldsDocument,
+            {
+                input: {
+                    id: assetId,
+                    translations: [
+                        {
+                            languageCode: LanguageCode.de,
+                            name: 'pps1.jpg',
+                            customFields: {
+                                alt: 'German alt text',
+                                title: 'German title',
+                            },
+                        },
+                    ],
+                },
+            },
+            { languageCode: LanguageCode.de },
+        );
+
+        expect(updateAsset.customFields.alt).toBe('German alt text');
+        expect(updateAsset.customFields.title).toBe('German title');
+    });
+
+    it('retrieves English translations when querying in English', async () => {
+        const { asset } = await adminClient.query(
+            getAssetWithCustomFieldsDocument,
+            { id: assetId },
+            { languageCode: LanguageCode.en },
+        );
+
+        expect(asset).not.toBeNull();
+        if (asset) {
+            expect(asset.customFields.alt).toBe('English alt text');
+            expect(asset.customFields.title).toBe('English title');
+        }
+    });
+
+    it('retrieves German translations when querying in German', async () => {
+        const { asset } = await adminClient.query(
+            getAssetWithCustomFieldsDocument,
+            { id: assetId },
+            { languageCode: LanguageCode.de },
+        );
+
+        expect(asset).not.toBeNull();
+        if (asset) {
+            expect(asset.customFields.alt).toBe('German alt text');
+            expect(asset.customFields.title).toBe('German title');
+        }
+    });
+
+    it('falls back to default language when translation is not available', async () => {
+        const { asset } = await adminClient.query(
+            getAssetWithCustomFieldsDocument,
+            { id: assetId },
+            { languageCode: LanguageCode.zh },
+        );
+
+        expect(asset).not.toBeNull();
+        if (asset) {
+            // Should fall back to English (the default language)
+            expect(asset.customFields.alt).toBe('English alt text');
+            expect(asset.customFields.title).toBe('English title');
+        }
+    });
+});
+
+const createAssetsWithCustomFieldsDocument = graphql(`
+    mutation CreateAssetsWithCustomFields($input: [CreateAssetInput!]!) {
+        createAssets(input: $input) {
+            ... on Asset {
+                id
+                name
+                customFields {
+                    alt
+                    title
+                }
+            }
+            ... on MimeTypeError {
+                message
+                fileName
+                mimeType
+            }
+        }
+    }
+`);
+
+const updateAssetWithCustomFieldsDocument = graphql(`
+    mutation UpdateAssetWithCustomFields($input: UpdateAssetInput!) {
+        updateAsset(input: $input) {
+            id
+            name
+            customFields {
+                alt
+                title
+            }
+        }
+    }
+`);
+
+const getAssetWithCustomFieldsDocument = graphql(`
+    query GetAssetWithCustomFields($id: ID!) {
+        asset(id: $id) {
+            id
+            name
+            customFields {
+                alt
+                title
+            }
+        }
+    }
+`);

+ 1 - 0
packages/core/e2e/custom-field-relations.e2e-spec.ts

@@ -146,6 +146,7 @@ describe('Custom field relations', () => {
             'id',
             'createdAt',
             'updatedAt',
+            'languageCode',
             'name',
             'type',
             'fileSize',

+ 2 - 0
packages/core/e2e/custom-fields.e2e-spec.ts

@@ -284,6 +284,7 @@ describe('Custom fields', () => {
                         'id',
                         'createdAt',
                         'updatedAt',
+                        'languageCode',
                         'name',
                         'type',
                         'fileSize',
@@ -347,6 +348,7 @@ describe('Custom fields', () => {
                         'id',
                         'createdAt',
                         'updatedAt',
+                        'languageCode',
                         'name',
                         'type',
                         'fileSize',

+ 1 - 0
packages/core/e2e/fixtures/test-plugins/hydration-test-plugin.ts

@@ -44,6 +44,7 @@ export class TestAdminPluginResolver {
                 'variants.options',
                 'variants.product',
                 'assets.product',
+                'assets.asset',
                 'facetValues.facet',
                 'featuredAsset',
                 'variants.stockMovements',

Файловите разлики са ограничени, защото са твърде много
+ 0 - 2
packages/core/e2e/graphql/graphql-env-admin.d.ts


Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
packages/core/e2e/graphql/graphql-env-shop.d.ts


+ 2 - 2
packages/core/e2e/order-multi-vendor.e2e-spec.ts

@@ -110,7 +110,7 @@ describe('Multi-vendor orders', () => {
             },
         });
 
-        expect(assignProductsToChannel[0].channels.map(c => c.code)).toEqual([
+        expect(assignProductsToChannel[0].channels.map(c => c.code).sort()).toEqual([
             '__default_channel__',
             'bobs-parts',
         ]);
@@ -125,7 +125,7 @@ describe('Multi-vendor orders', () => {
                 priceFactor: 1,
             },
         });
-        expect(result2[0].channels.map(c => c.code)).toEqual(['__default_channel__', 'alices-wares']);
+        expect(result2[0].channels.map(c => c.code).sort()).toEqual(['__default_channel__', 'alices-wares']);
         alicesWaresChannel.variantIds = result2[0].variants.map(v => v.id);
 
         expect(alicesWaresChannel.variantIds).toEqual(['T_22']);

+ 4 - 4
packages/core/src/api/resolvers/admin/asset.resolver.ts

@@ -1,6 +1,5 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
-    CreateAssetResult,
     MutationAssignAssetsToChannelArgs,
     MutationCreateAssetsArgs,
     MutationDeleteAssetArgs,
@@ -12,7 +11,8 @@ import {
 } from '@vendure/common/lib/generated-types';
 import { PaginatedList } from '@vendure/common/lib/shared-types';
 
-import { Administrator } from '../../../entity/administrator/administrator.entity';
+import { MimeTypeError } from '../../../common/error/generated-graphql-admin-errors';
+import { Translated } from '../../../common/types/locale-types';
 import { Asset } from '../../../entity/asset/asset.entity';
 import { AssetService } from '../../../service/services/asset.service';
 import { RequestContext } from '../../common/request-context';
@@ -51,10 +51,10 @@ export class AssetResolver {
     async createAssets(
         @Ctx() ctx: RequestContext,
         @Args() args: MutationCreateAssetsArgs,
-    ): Promise<CreateAssetResult[]> {
+    ): Promise<Array<Translated<Asset> | MimeTypeError>> {
         // TODO: Is there some way to parellelize this while still preserving
         // the order of files in the upload? Non-deterministic IDs mess up the e2e test snapshots.
-        const assets: CreateAssetResult[] = [];
+        const assets: Array<Translated<Asset> | MimeTypeError> = [];
         for (const input of args.input) {
             const asset = await this.assetService.create(ctx, input);
             assets.push(asset);

+ 29 - 1
packages/core/src/api/resolvers/entity/asset-entity.resolver.ts

@@ -2,13 +2,41 @@ import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 
 import { Asset } from '../../../entity/asset/asset.entity';
 import { Tag } from '../../../entity/tag/tag.entity';
+import { LocaleStringHydrator } from '../../../service/helpers/locale-string-hydrator/locale-string-hydrator';
 import { TagService } from '../../../service/services/tag.service';
 import { RequestContext } from '../../common/request-context';
 import { Ctx } from '../../decorators/request-context.decorator';
 
 @Resolver('Asset')
 export class AssetEntityResolver {
-    constructor(private tagService: TagService) {}
+    constructor(
+        private readonly tagService: TagService,
+        private readonly localeStringHydrator: LocaleStringHydrator,
+    ) {}
+
+    @ResolveField()
+    async name(@Ctx() ctx: RequestContext, @Parent() asset: Asset): Promise<string> {
+        // Handle assets without translations (legacy data)
+        if (!asset.translations || asset.translations.length === 0) {
+            return (asset as any).name ?? '';
+        }
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, asset, 'name');
+    }
+
+    @ResolveField()
+    async languageCode(@Ctx() ctx: RequestContext, @Parent() asset: Asset): Promise<string> {
+        // Handle assets without translations (legacy data)
+        if (!asset.translations || asset.translations.length === 0) {
+            return ctx.languageCode;
+        }
+        return this.localeStringHydrator.hydrateLocaleStringField(ctx, asset, 'languageCode');
+    }
+
+    @ResolveField()
+    async translations(@Ctx() ctx: RequestContext, @Parent() asset: Asset): Promise<any[]> {
+        // Return empty array for assets without translations (legacy data)
+        return asset.translations ?? [];
+    }
 
     @ResolveField()
     async tags(@Ctx() ctx: RequestContext, @Parent() asset: Asset): Promise<Tag[]> {

+ 8 - 0
packages/core/src/api/schema/admin-api/asset.api.graphql

@@ -33,9 +33,16 @@ input AssetListOptions {
     tagsOperator: LogicalOperator
 }
 
+input AssetTranslationInput {
+    id: ID
+    languageCode: LanguageCode!
+    name: String
+}
+
 input CreateAssetInput {
     file: Upload!
     tags: [String!]
+    translations: [AssetTranslationInput!]
 }
 
 input CoordinateInput {
@@ -60,6 +67,7 @@ input UpdateAssetInput {
     name: String
     focalPoint: CoordinateInput
     tags: [String!]
+    translations: [AssetTranslationInput!]
 }
 
 input AssignAssetsToChannelInput {

+ 10 - 0
packages/core/src/api/schema/common/asset.type.graphql

@@ -2,6 +2,7 @@ type Asset implements Node {
     id: ID!
     createdAt: DateTime!
     updatedAt: DateTime!
+    languageCode: LanguageCode!
     name: String!
     type: AssetType!
     fileSize: Int!
@@ -12,6 +13,15 @@ type Asset implements Node {
     preview: String!
     focalPoint: Coordinate
     tags: [Tag!]!
+    translations: [AssetTranslation!]!
+}
+
+type AssetTranslation {
+    id: ID!
+    createdAt: DateTime!
+    updatedAt: DateTime!
+    languageCode: LanguageCode!
+    name: String!
 }
 
 type Coordinate {

+ 2 - 0
packages/core/src/common/types/locale-types.ts

@@ -40,6 +40,8 @@ export type Translation<T> =
 // Translation must include the languageCode and a reference to the base Translatable entity it is associated with
     {
         id: ID;
+        createdAt: Date;
+        updatedAt: Date;
         languageCode: LanguageCode;
         base: T;
     } &

+ 1 - 1
packages/core/src/data-import/providers/importer/importer.ts

@@ -250,7 +250,7 @@ export class Importer {
                     variant.translations,
                     ctx.languageCode,
                 );
-                const createVariantAssets = await this.assetImporter.getAssets(variant.assetPaths);
+                const createVariantAssets = await this.assetImporter.getAssets(variant.assetPaths, ctx);
                 const variantAssets = createVariantAssets.assets;
                 if (createVariantAssets.errors.length) {
                     errors = errors.concat(createVariantAssets.errors);

+ 28 - 0
packages/core/src/entity/asset/asset-translation.entity.ts

@@ -0,0 +1,28 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { Column, Entity, Index, ManyToOne } from 'typeorm';
+
+import { Translation } from '../../common/types/locale-types';
+import { HasCustomFields } from '../../config/custom-field/custom-field-types';
+import { VendureEntity } from '../base/base.entity';
+import { CustomAssetFieldsTranslation } from '../custom-entity-fields';
+
+import { Asset } from './asset.entity';
+
+@Entity()
+export class AssetTranslation extends VendureEntity implements Translation<Asset>, HasCustomFields {
+    constructor(input?: DeepPartial<Translation<Asset>>) {
+        super(input);
+    }
+
+    @Column('varchar') languageCode: LanguageCode;
+
+    @Column() name: string;
+
+    @Index()
+    @ManyToOne(type => Asset, base => base.translations, { onDelete: 'CASCADE' })
+    base: Asset;
+
+    @Column(type => CustomAssetFieldsTranslation)
+    customFields: CustomAssetFieldsTranslation;
+}

+ 9 - 3
packages/core/src/entity/asset/asset.entity.ts

@@ -3,15 +3,18 @@ import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinTable, ManyToMany, OneToMany } from 'typeorm';
 
 import { ChannelAware, Taggable } from '../../common/types/common-types';
+import { LocaleString, Translatable, Translation } from '../../common/types/locale-types';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
 import { VendureEntity } from '../base/base.entity';
 import { Channel } from '../channel/channel.entity';
 import { Collection } from '../collection/collection.entity';
 import { CustomAssetFields } from '../custom-entity-fields';
-import { Product } from '../product/product.entity';
 import { ProductVariant } from '../product-variant/product-variant.entity';
+import { Product } from '../product/product.entity';
 import { Tag } from '../tag/tag.entity';
 
+import { AssetTranslation } from './asset-translation.entity';
+
 /**
  * @description
  * An Asset represents a file such as an image which can be associated with certain other entities
@@ -20,12 +23,12 @@ import { Tag } from '../tag/tag.entity';
  * @docsCategory entities
  */
 @Entity()
-export class Asset extends VendureEntity implements Taggable, ChannelAware, HasCustomFields {
+export class Asset extends VendureEntity implements Taggable, ChannelAware, HasCustomFields, Translatable {
     constructor(input?: DeepPartial<Asset>) {
         super(input);
     }
 
-    @Column() name: string;
+    name: LocaleString;
 
     @Column('varchar') type: AssetType;
 
@@ -63,4 +66,7 @@ export class Asset extends VendureEntity implements Taggable, ChannelAware, HasC
 
     @Column(type => CustomAssetFields)
     customFields: CustomAssetFields;
+
+    @OneToMany(type => AssetTranslation, translation => translation.base, { eager: true })
+    translations: Array<Translation<Asset>>;
 }

+ 1 - 0
packages/core/src/entity/custom-entity-fields.ts

@@ -3,6 +3,7 @@ export class CustomAdministratorFields {}
 export class CustomApiKeyFields {}
 export class CustomApiKeyFieldsTranslation {}
 export class CustomAssetFields {}
+export class CustomAssetFieldsTranslation {}
 export class CustomChannelFields {}
 export class CustomCollectionFields {}
 export class CustomCollectionFieldsTranslation {}

+ 2 - 0
packages/core/src/entity/entities.ts

@@ -2,6 +2,7 @@ import { Address } from './address/address.entity';
 import { Administrator } from './administrator/administrator.entity';
 import { ApiKeyTranslation } from './api-key/api-key-translation.entity';
 import { ApiKey } from './api-key/api-key.entity';
+import { AssetTranslation } from './asset/asset-translation.entity';
 import { Asset } from './asset/asset.entity';
 import { AuthenticationMethod } from './authentication-method/authentication-method.entity';
 import { ExternalAuthenticationMethod } from './authentication-method/external-authentication-method.entity';
@@ -84,6 +85,7 @@ export const coreEntitiesMap = {
     ApiKey,
     ApiKeyTranslation,
     Asset,
+    AssetTranslation,
     AuthenticatedSession,
     AuthenticationMethod,
     Cancellation,

+ 1 - 0
packages/core/src/entity/index.ts

@@ -1,5 +1,6 @@
 export * from './address/address.entity';
 export * from './administrator/administrator.entity';
+export * from './asset/asset-translation.entity';
 export * from './asset/asset.entity';
 export * from './asset/orderable-asset.entity';
 export * from './authentication-method/authentication-method.entity';

+ 40 - 2
packages/core/src/service/helpers/entity-hydrator/entity-hydrator.service.ts

@@ -117,6 +117,16 @@ export class EntityHydrator {
                 missingRelations = unique([...missingRelations, ...productVariantPriceRelations]);
             }
 
+            // Add .translations relations for translatable entities
+            // Note: For nested relations through arrays (like assets.asset), we rely on eager loading
+            // on the Asset.translations relation since explicitly loading deeply nested relations
+            // can cause issues with TypeORM's relation loading
+            const translationRelations = this.getTranslationRelationsForTranslatableEntities(
+                target,
+                missingRelations,
+            );
+            missingRelations = unique([...missingRelations, ...translationRelations]);
+
             if (missingRelations.length) {
                 const hydratedQb: SelectQueryBuilder<any> = this.connection
                     .getRepository(ctx, target.constructor)
@@ -306,9 +316,37 @@ export class EntityHydrator {
         return currentMetadata.target as Type<VendureEntity>;
     }
 
+    /**
+     * Returns additional .translations relations for any translatable entities in the relations list.
+     * This ensures that translatable nested relations have their translations loaded.
+     */
+    private getTranslationRelationsForTranslatableEntities<Entity extends VendureEntity>(
+        target: Entity,
+        missingRelations: string[],
+    ): string[] {
+        const translationRelations: string[] = [];
+        for (const relation of missingRelations) {
+            try {
+                const entityType = this.getRelationEntityTypeAtPath(target, relation);
+                // Check if the entity type has a translations property in its metadata
+                const { entityMetadatas } = this.connection.rawConnection;
+                const entityMetadata = entityMetadatas.find(m => m.target === entityType);
+                if (entityMetadata) {
+                    const translationsRelation = entityMetadata.findRelationWithPropertyPath('translations');
+                    if (translationsRelation) {
+                        translationRelations.push(`${relation}.translations`);
+                    }
+                }
+            } catch {
+                // If we can't find the entity type, skip this relation
+            }
+        }
+        return translationRelations;
+    }
+
     private isTranslatable<T extends VendureEntity>(input: T | T[] | undefined): boolean {
         return Array.isArray(input)
-            ? input[0]?.hasOwnProperty('translations') ?? false
-            : input?.hasOwnProperty('translations') ?? false;
+            ? (input[0]?.hasOwnProperty('translations') ?? false)
+            : (input?.hasOwnProperty('translations') ?? false);
     }
 }

+ 3 - 2
packages/core/src/service/helpers/translatable-saver/translation-differ.ts

@@ -1,4 +1,4 @@
-import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../../api/common/request-context';
 import { InternalServerError } from '../../../common/error/errors';
@@ -18,7 +18,7 @@ export interface TranslationDiff<T> {
 /**
  * This class is to be used when performing an update on a Translatable entity.
  */
-export class TranslationDiffer<Entity extends Translatable> {
+export class TranslationDiffer<Entity extends Translatable & { id: ID }> {
     constructor(
         private translationCtor: TranslationContructor<Entity>,
         private connection: TransactionalConnection,
@@ -65,6 +65,7 @@ export class TranslationDiffer<Entity extends Translatable> {
         if (toAdd.length) {
             for (const translation of toAdd) {
                 translation.base = entity;
+                (translation as any).baseId = entity.id;
                 let newTranslation: any;
                 try {
                     newTranslation = await this.connection

+ 18 - 2
packages/core/src/service/helpers/utils/translate-entity.ts

@@ -144,9 +144,25 @@ function translateLeaf(
 ): any {
     if (object && object[property]) {
         if (Array.isArray(object[property])) {
-            return object[property].map((nested2: any) => translateEntity(nested2, languageCode));
+            return object[property].map((nested2: any) => {
+                try {
+                    return translateEntity(nested2, languageCode);
+                } catch (e: any) {
+                    if (e instanceof InternalServerError) {
+                        return nested2;
+                    }
+                    throw e;
+                }
+            });
         } else if (object[property]) {
-            return translateEntity(object[property], languageCode);
+            try {
+                return translateEntity(object[property], languageCode);
+            } catch (e: any) {
+                if (e instanceof InternalServerError) {
+                    return object[property];
+                }
+                throw e;
+            }
         }
     }
 }

+ 99 - 22
packages/core/src/service/services/asset.service.ts

@@ -4,9 +4,9 @@ import {
     AssetType,
     AssignAssetsToChannelInput,
     CreateAssetInput,
-    CreateAssetResult,
     DeletionResponse,
     DeletionResult,
+    LanguageCode,
     LogicalOperator,
     Permission,
     UpdateAssetInput,
@@ -32,10 +32,12 @@ import { isGraphQlErrorResult } from '../../common/error/error-result';
 import { ForbiddenError, InternalServerError } from '../../common/error/errors';
 import { MimeTypeError } from '../../common/error/generated-graphql-admin-errors';
 import { ChannelAware } from '../../common/types/common-types';
+import { Translated } from '../../common/types/locale-types';
 import { getAssetType, idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
 import { Logger } from '../../config/logger/vendure-logger';
 import { TransactionalConnection } from '../../connection/transactional-connection';
+import { AssetTranslation } from '../../entity/asset/asset-translation.entity';
 import { Asset } from '../../entity/asset/asset.entity';
 import { OrderableAsset } from '../../entity/asset/orderable-asset.entity';
 import { VendureEntity } from '../../entity/base/base.entity';
@@ -47,6 +49,8 @@ import { AssetChannelEvent } from '../../event-bus/events/asset-channel-event';
 import { AssetEvent } from '../../event-bus/events/asset-event';
 import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
+import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
+import { TranslatorService } from '../helpers/translator/translator.service';
 import { patchEntity } from '../helpers/utils/patch-entity';
 
 import { ChannelService } from './channel.service';
@@ -102,6 +106,8 @@ export class AssetService {
         private channelService: ChannelService,
         private roleService: RoleService,
         private customFieldRelationService: CustomFieldRelationService,
+        private readonly translatableSaver: TranslatableSaver,
+        private readonly translator: TranslatorService,
     ) {
         this.permittedMimeTypes = this.configService.assetOptions.permittedFileTypes
             .map(val => (/\.[\w]+/.test(val) ? mime.lookup(val) || undefined : val))
@@ -112,19 +118,23 @@ export class AssetService {
             });
     }
 
-    findOne(ctx: RequestContext, id: ID, relations?: RelationPaths<Asset>): Promise<Asset | undefined> {
+    findOne(
+        ctx: RequestContext,
+        id: ID,
+        relations?: RelationPaths<Asset>,
+    ): Promise<Translated<Asset> | undefined> {
         return this.connection
             .findOneInChannel(ctx, Asset, id, ctx.channelId, {
                 relations: relations ?? [],
             })
-            .then(result => result ?? undefined);
+            .then(result => (result ? this.translator.translate(result, ctx) : undefined));
     }
 
     findAll(
         ctx: RequestContext,
         options?: AssetListOptions,
         relations?: RelationPaths<Asset>,
-    ): Promise<PaginatedList<Asset>> {
+    ): Promise<PaginatedList<Translated<Asset>>> {
         const qb = this.listQueryBuilder.build(Asset, options, {
             ctx,
             relations: [...(relations ?? []), 'tags'],
@@ -150,7 +160,7 @@ export class AssetService {
             });
         }
         return qb.getManyAndCount().then(([items, totalItems]) => ({
-            items,
+            items: items.map(asset => this.translator.translate(asset, ctx)),
             totalItems,
         }));
     }
@@ -206,6 +216,7 @@ export class AssetService {
                 .createQueryBuilder('entity')
                 .leftJoinAndSelect('entity.assets', 'orderable_asset')
                 .leftJoinAndSelect('orderable_asset.asset', 'asset')
+                .leftJoinAndSelect('asset.translations', 'asset_translations')
                 .leftJoinAndSelect('asset.channels', 'asset_channel')
                 .where('entity.id = :id', { id: entity.id })
                 .andWhere('asset_channel.id = :channelId', { channelId: ctx.channelId })
@@ -291,11 +302,11 @@ export class AssetService {
      *
      * See the [Uploading Files docs](/developer-guide/uploading-files) for an example of usage.
      */
-    async create(ctx: RequestContext, input: CreateAssetInput): Promise<CreateAssetResult> {
+    async create(ctx: RequestContext, input: CreateAssetInput): Promise<Translated<Asset> | MimeTypeError> {
         const { createReadStream, filename, mimetype } = await input.file;
         const { stream, errorPromise } = this.makeStreamGuard(createReadStream);
         const result = await Promise.race([
-            this.createAssetInternal(ctx, stream, filename, mimetype, input.customFields),
+            this.createAssetInternal(ctx, stream, filename, mimetype, input.customFields, input.translations),
             errorPromise,
         ]);
         if (isGraphQlErrorResult(result)) {
@@ -307,29 +318,49 @@ export class AssetService {
             result.tags = tags;
             await this.connection.getRepository(ctx, Asset).save(result);
         }
-        await this.eventBus.publish(new AssetEvent(ctx, result, 'created', input));
-        return result;
+        const translatedAsset = this.translator.translate(result, ctx);
+        await this.eventBus.publish(new AssetEvent(ctx, translatedAsset, 'created', input));
+        return translatedAsset;
     }
 
     /**
      * @description
      * Updates the name, focalPoint, tags & custom fields of an Asset.
      */
-    async update(ctx: RequestContext, input: UpdateAssetInput): Promise<Asset> {
+    async update(ctx: RequestContext, input: UpdateAssetInput): Promise<Translated<Asset>> {
         const asset = await this.connection.getEntityOrThrow(ctx, Asset, input.id);
         if (input.focalPoint) {
             const to3dp = (x: number) => +x.toFixed(3);
             input.focalPoint.x = to3dp(input.focalPoint.x);
             input.focalPoint.y = to3dp(input.focalPoint.y);
         }
-        patchEntity(asset, omit(input, ['tags']));
+        patchEntity(asset, omit(input, ['tags', 'name', 'translations']));
         await this.customFieldRelationService.updateRelations(ctx, Asset, input, asset);
         if (input.tags) {
             asset.tags = await this.tagService.valuesToTags(ctx, input.tags);
         }
-        const updatedAsset = await this.connection.getRepository(ctx, Asset).save(asset);
-        await this.eventBus.publish(new AssetEvent(ctx, updatedAsset, 'updated', input));
-        return updatedAsset;
+        // Handle translations
+        const translationsInput = input.translations ?? [];
+        // For backward compatibility: if name is provided without translations, update the current language translation
+        if (input.name != null && !translationsInput.some(t => t.languageCode === ctx.languageCode)) {
+            translationsInput.push({ languageCode: ctx.languageCode, name: input.name });
+        }
+        // Save asset first to ensure it exists for translation foreign key
+        const savedAsset = await this.connection.getRepository(ctx, Asset).save(asset);
+        if (translationsInput.length > 0) {
+            await this.translatableSaver.update({
+                ctx,
+                input: { id: savedAsset.id, translations: translationsInput },
+                entityType: Asset,
+                translationType: AssetTranslation,
+            });
+        }
+        const translatedAsset = await this.findOne(ctx, savedAsset.id);
+        if (!translatedAsset) {
+            throw new InternalServerError('error.entity-not-found');
+        }
+        await this.eventBus.publish(new AssetEvent(ctx, translatedAsset, 'updated', input));
+        return translatedAsset;
     }
 
     /**
@@ -402,7 +433,10 @@ export class AssetService {
         return this.deleteUnconditional(ctx, assets);
     }
 
-    async assignToChannel(ctx: RequestContext, input: AssignAssetsToChannelInput): Promise<Asset[]> {
+    async assignToChannel(
+        ctx: RequestContext,
+        input: AssignAssetsToChannelInput,
+    ): Promise<Array<Translated<Asset>>> {
         const hasPermission = await this.roleService.userHasPermissionOnChannel(
             ctx,
             input.channelId,
@@ -426,30 +460,34 @@ export class AssetService {
                 );
             }),
         );
-        return this.connection.findByIdsInChannel(
+        const updatedAssets = await this.connection.findByIdsInChannel(
             ctx,
             Asset,
             assets.map(a => a.id),
             ctx.channelId,
             {},
         );
+        return updatedAssets.map(asset => this.translator.translate(asset, ctx));
     }
 
     /**
      * @description
      * Create an Asset from a file stream, for example to create an Asset during data import.
      */
-    async createFromFileStream(stream: ReadStream, ctx?: RequestContext): Promise<CreateAssetResult>;
+    async createFromFileStream(
+        stream: ReadStream,
+        ctx?: RequestContext,
+    ): Promise<Translated<Asset> | MimeTypeError>;
     async createFromFileStream(
         stream: Readable,
         filePath: string,
         ctx?: RequestContext,
-    ): Promise<CreateAssetResult>;
+    ): Promise<Translated<Asset> | MimeTypeError>;
     async createFromFileStream(
         stream: ReadStream | Readable,
         maybeFilePathOrCtx?: string | RequestContext,
         maybeCtx?: RequestContext,
-    ): Promise<CreateAssetResult> {
+    ): Promise<Translated<Asset> | MimeTypeError> {
         const { assetImportStrategy } = this.configService.importExportOptions;
         const filePathFromArgs =
             maybeFilePathOrCtx instanceof RequestContext ? undefined : maybeFilePathOrCtx;
@@ -464,7 +502,11 @@ export class AssetService {
                     : maybeCtx instanceof RequestContext
                       ? maybeCtx
                       : RequestContext.empty();
-            return this.createAssetInternal(ctx, stream, filename, mimetype);
+            const result = await this.createAssetInternal(ctx, stream, filename, mimetype);
+            if (isGraphQlErrorResult(result)) {
+                return result;
+            }
+            return this.translator.translate(result, ctx);
         } else {
             throw new InternalServerError('error.path-should-be-a-string-got-buffer');
         }
@@ -522,6 +564,7 @@ export class AssetService {
         filename: string,
         mimetype: string,
         customFields?: { [key: string]: any },
+        translations?: Array<{ languageCode: LanguageCode; name?: string | null; customFields?: any }>,
     ): Promise<Asset | MimeTypeError> {
         const { assetOptions } = this.configService;
         if (!this.validateMimeType(mimetype)) {
@@ -548,11 +591,11 @@ export class AssetService {
         const type = getAssetType(mimetype);
         const { width, height } = this.getDimensions(type === AssetType.IMAGE ? sourceFile : preview);
 
+        // Save asset first
         const asset = new Asset({
             type,
             width,
             height,
-            name: path.basename(sourceFileName),
             fileSize: sourceFile.byteLength,
             mimeType: mimetype,
             source: sourceFileIdentifier,
@@ -561,7 +604,41 @@ export class AssetService {
             customFields,
         });
         await this.channelService.assignToCurrentChannel(asset, ctx);
-        return this.connection.getRepository(ctx, Asset).save(asset);
+        const savedAsset = await this.connection.getRepository(ctx, Asset).save(asset);
+
+        // Create and save translations with the base relationship set
+        // Use the original filename for the default translation name
+        const defaultName = filename;
+        let assetTranslations: AssetTranslation[];
+        if (translations && translations.length > 0) {
+            assetTranslations = translations.map(
+                t =>
+                    new AssetTranslation({
+                        languageCode: t.languageCode,
+                        name: t.name ?? defaultName,
+                        customFields: t.customFields,
+                        base: savedAsset,
+                    }),
+            );
+        } else {
+            // Create default translation using context language
+            assetTranslations = [
+                new AssetTranslation({
+                    languageCode: ctx.languageCode,
+                    name: defaultName,
+                    base: savedAsset,
+                }),
+            ];
+        }
+
+        // Save translations
+        const savedTranslations = await this.connection
+            .getRepository(ctx, AssetTranslation)
+            .save(assetTranslations);
+
+        // Return the asset with translations eagerly loaded
+        savedAsset.translations = savedTranslations;
+        return savedAsset;
     }
 
     private async getSourceFileName(ctx: RequestContext, fileName: string): Promise<string> {

+ 3 - 3
packages/dashboard/src/app/routeTree.gen.ts

@@ -395,8 +395,8 @@ const AuthenticatedProductsProductsProductIdOptionGroupsProductOptionGroupIdOpti
   )
 
 export interface FileRoutesByFullPath {
-  '/': typeof AuthenticatedIndexRoute
   '/login': typeof LoginRoute
+  '/': typeof AuthenticatedIndexRoute
   '/administrators': typeof AuthenticatedAdministratorsAdministratorsRoute
   '/assets': typeof AuthenticatedAssetsAssetsRoute
   '/channels': typeof AuthenticatedChannelsChannelsRoute
@@ -567,8 +567,8 @@ export interface FileRoutesById {
 export interface FileRouteTypes {
   fileRoutesByFullPath: FileRoutesByFullPath
   fullPaths:
-    | '/'
     | '/login'
+    | '/'
     | '/administrators'
     | '/assets'
     | '/channels'
@@ -753,7 +753,7 @@ declare module '@tanstack/react-router' {
     '/_authenticated': {
       id: '/_authenticated'
       path: ''
-      fullPath: '/'
+      fullPath: ''
       preLoaderRoute: typeof AuthenticatedRouteImport
       parentRoute: typeof rootRouteImport
     }

+ 14 - 6
packages/dashboard/src/app/routes/_authenticated/_assets/assets.graphql.ts

@@ -17,13 +17,21 @@ export const assetDetailDocument = graphql(
     [assetFragment],
 );
 
-export const assetUpdateDocument = graphql(`
-    mutation AssetUpdate($input: UpdateAssetInput!) {
-        updateAsset(input: $input) {
-            id
+export const assetUpdateDocument = graphql(
+    `
+        mutation AssetUpdate($input: UpdateAssetInput!) {
+            updateAsset(input: $input) {
+                ...Asset
+                tags {
+                    id
+                    value
+                }
+                customFields
+            }
         }
-    }
-`);
+    `,
+    [assetFragment],
+);
 
 export const deleteAssetsDocument = graphql(`
     mutation DeleteAssets($input: DeleteAssetsInput!) {

+ 27 - 2
packages/dashboard/src/app/routes/_authenticated/_assets/assets_.$id.tsx

@@ -3,10 +3,13 @@ import { AssetPreviewSelector } from '@/vdb/components/shared/asset/asset-previe
 import { PreviewPreset } from '@/vdb/components/shared/asset/asset-preview.js';
 import { AssetProperties } from '@/vdb/components/shared/asset/asset-properties.js';
 import { ErrorPage } from '@/vdb/components/shared/error-page.js';
+import { TranslatableFormFieldWrapper } from '@/vdb/components/shared/translatable-form-field.js';
 import { VendureImage } from '@/vdb/components/shared/vendure-image.js';
 import { Button } from '@/vdb/components/ui/button.js';
+import { Input } from '@/vdb/components/ui/input.js';
 import { Label } from '@/vdb/components/ui/label.js';
-import {    CustomFieldsPageBlock,
+import {
+    CustomFieldsPageBlock,
     Page,
     PageActionBar,
     PageBlock,
@@ -55,10 +58,24 @@ function AssetDetailPage() {
         queryDocument: assetDetailDocument,
         updateDocument: assetUpdateDocument,
         setValuesForUpdate: entity => {
+            // Handle legacy assets without translations
+            const translations =
+                entity.translations && entity.translations.length > 0
+                    ? entity.translations.map(t => ({
+                          id: t.id,
+                          languageCode: t.languageCode,
+                          name: t.name,
+                      }))
+                    : [
+                          {
+                              languageCode: entity.languageCode,
+                              name: entity.name,
+                          },
+                      ];
             return {
                 id: entity.id,
                 focalPoint: entity.focalPoint,
-                name: entity.name,
+                translations,
                 tags: entity.tags?.map(tag => tag.value) ?? [],
                 customFields: entity.customFields,
             };
@@ -128,6 +145,14 @@ function AssetDetailPage() {
                     </div>
                 </PageBlock>
                 <CustomFieldsPageBlock column="main" entityType={'Asset'} control={form.control} />
+                <PageBlock column="side" blockId="asset-name">
+                    <TranslatableFormFieldWrapper
+                        control={form.control}
+                        name="name"
+                        label={<Trans>Name</Trans>}
+                        render={({ field }) => <Input {...field} />}
+                    />
+                </PageBlock>
                 <PageBlock column="side" blockId="asset-properties">
                     <AssetProperties asset={entity} />
                 </PageBlock>

+ 6 - 2
packages/dashboard/src/app/routes/_authenticated/_products/components/create-product-variants-dialog.tsx

@@ -10,7 +10,8 @@ import {
 } from '@/vdb/components/ui/dialog.js';
 import { api } from '@/vdb/graphql/api.js';
 import { useChannel } from '@/vdb/hooks/use-channel.js';
-import { Trans } from '@lingui/react/macro';
+import { Trans, useLingui } from '@lingui/react/macro';
+import { toast } from 'sonner';
 import { normalizeString } from '@/vdb/lib/utils.js';
 import { useMutation } from '@tanstack/react-query';
 import { Plus } from 'lucide-react';
@@ -31,6 +32,7 @@ export function CreateProductVariantsDialog({
     productName: string;
     onSuccess?: () => void;
 }) {
+    const { t } = useLingui();
     const { activeChannel } = useChannel();
     const [variantData, setVariantData] = useState<VariantConfiguration | null>(null);
     const [open, setOpen] = useState(false);
@@ -128,7 +130,9 @@ export function CreateProductVariantsDialog({
             onSuccess?.();
         } catch (error) {
             console.error('Error creating variants:', error);
-            // Handle error (show toast notification, etc.)
+            toast.error(t`Failed to create product variants`, {
+                description: error instanceof Error ? error.message : t`Unknown error`,
+            });
         }
     }
 

+ 0 - 4
packages/dashboard/src/lib/components/shared/asset/asset-properties.tsx

@@ -11,10 +11,6 @@ export interface AssetPropertiesProps {
 export function AssetProperties({ asset }: Readonly<AssetPropertiesProps>) {
     return (
         <div className="space-y-4">
-            <div>
-                <Label>Name</Label>
-                <p className="truncate text-sm text-muted-foreground">{asset.name}</p>
-            </div>
             <div>
                 <Label>Source File</Label>
                 <a

+ 42 - 5
packages/dashboard/src/lib/components/shared/translatable-form-field.tsx

@@ -3,7 +3,8 @@ import { LocationWrapper } from '@/vdb/framework/layout-engine/location-wrapper.
 import { useLocalFormat } from '@/vdb/hooks/use-local-format.js';
 import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
 import { Trans } from '@lingui/react/macro';
-import { Controller, ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
+import { useEffect } from 'react';
+import { Controller, ControllerProps, FieldPath, FieldValues, useFormContext } from 'react-hook-form';
 import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from '../ui/form.js';
 import { FormFieldWrapper } from './form-field-wrapper.js';
 
@@ -48,12 +49,14 @@ export const TranslatableFormField = <
   }: TranslatableFormFieldProps<TFieldValues>) => {
     const { formatLanguageName } = useLocalFormat();
     const { contentLanguage } = useUserSettings().settings;
-    const formValues = props.control?._formValues;
-    const translations = Array.isArray(formValues) ? formValues?.[0].translations : formValues?.translations;
+    const { watch } = useFormContext();
+    const formValues = watch();
+    const translations = Array.isArray(formValues) ? formValues?.[0]?.translations : formValues?.translations;
     const existingIndex = translations?.findIndex(
         (translation: any) => translation?.languageCode === contentLanguage,
     );
-    const index = existingIndex === -1 ? translations?.length : existingIndex;
+    const isNewTranslation = existingIndex === -1;
+    const index = isNewTranslation ? translations?.length : existingIndex;
     if (index === undefined || index === -1) {
         return (
             <FormItem>
@@ -65,7 +68,41 @@ export const TranslatableFormField = <
         );
     }
     const translationName = `translations.${index}.${String(name)}` as FieldPath<TFieldValues>;
-    return <Controller {...props} name={translationName} key={translationName} />;
+    return (
+        <TranslatableFieldController
+            {...props}
+            name={translationName}
+            index={index}
+            isNewTranslation={isNewTranslation}
+            contentLanguage={contentLanguage}
+        />
+    );
+};
+
+const TranslatableFieldController = <TFieldValues extends TranslatableEntity | TranslatableEntity[]>({
+    index,
+    isNewTranslation,
+    contentLanguage,
+    ...props
+}: Omit<ControllerProps<TFieldValues>, 'name'> & {
+    name: FieldPath<TFieldValues>;
+    index: number;
+    isNewTranslation: boolean;
+    contentLanguage: string;
+}) => {
+    const { setValue, getValues } = useFormContext();
+
+    useEffect(() => {
+        if (isNewTranslation) {
+            const translations = getValues('translations') || [];
+            const currentLangCode = translations[index]?.languageCode;
+            if (currentLangCode !== contentLanguage) {
+                setValue(`translations.${index}.languageCode`, contentLanguage, { shouldDirty: true });
+            }
+        }
+    }, [isNewTranslation, index, contentLanguage, setValue, getValues]);
+
+    return <Controller key={`${props.name}-${contentLanguage}`} {...props} />;
 };
 
 export type TranslatableFormFieldWrapperProps<

+ 6 - 0
packages/dashboard/src/lib/graphql/fragments.ts

@@ -5,6 +5,7 @@ export const assetFragment = graphql(`
         id
         createdAt
         updatedAt
+        languageCode
         name
         fileSize
         mimeType
@@ -17,6 +18,11 @@ export const assetFragment = graphql(`
             x
             y
         }
+        translations {
+            id
+            languageCode
+            name
+        }
     }
 `);
 

Файловите разлики са ограничени, защото са твърде много
+ 0 - 2
packages/dashboard/src/lib/graphql/graphql-env.d.ts


Файловите разлики са ограничени, защото са твърде много
+ 3 - 5
packages/dev-server/graphql/graphql-env.d.ts


+ 8 - 2
packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts

@@ -337,7 +337,10 @@ describe('Elasticsearch plugin', () => {
                     groupByProduct: false,
                 },
             });
-            expect(result.search.collections).toEqual([
+            const sortedCollections = [...result.search.collections].sort((a, b) =>
+                a.collection.id.localeCompare(b.collection.id),
+            );
+            expect(sortedCollections).toEqual([
                 { collection: { id: 'T_2', name: 'Plants' }, count: 4 },
                 { collection: { id: 'T_3', name: 'Electronics' }, count: 21 },
             ]);
@@ -349,7 +352,10 @@ describe('Elasticsearch plugin', () => {
                     groupByProduct: true,
                 },
             });
-            expect(result.search.collections).toEqual([
+            const sortedCollections = [...result.search.collections].sort((a, b) =>
+                a.collection.id.localeCompare(b.collection.id),
+            );
+            expect(sortedCollections).toEqual([
                 { collection: { id: 'T_2', name: 'Plants' }, count: 4 },
                 { collection: { id: 'T_3', name: 'Electronics' }, count: 10 },
             ]);

Файловите разлики са ограничени, защото са твърде много
+ 0 - 2
packages/elasticsearch-plugin/e2e/graphql/graphql-env-admin.d.ts


Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
packages/elasticsearch-plugin/e2e/graphql/graphql-env-shop.d.ts


Файловите разлики са ограничени, защото са твърде много
+ 0 - 2
packages/payments-plugin/e2e/graphql/graphql-env-admin.d.ts


Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
packages/payments-plugin/e2e/graphql/graphql-env-shop.d.ts


+ 11 - 0
packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts

@@ -92,11 +92,13 @@ export type Asset = Node & {
     focalPoint?: Maybe<Coordinate>;
     height: Scalars['Int']['output'];
     id: Scalars['ID']['output'];
+    languageCode: LanguageCode;
     mimeType: Scalars['String']['output'];
     name: Scalars['String']['output'];
     preview: Scalars['String']['output'];
     source: Scalars['String']['output'];
     tags: Array<Tag>;
+    translations: Array<AssetTranslation>;
     type: AssetType;
     updatedAt: Scalars['DateTime']['output'];
     width: Scalars['Int']['output'];
@@ -108,6 +110,15 @@ export type AssetList = PaginatedList & {
     totalItems: Scalars['Int']['output'];
 };
 
+export type AssetTranslation = {
+    __typename?: 'AssetTranslation';
+    createdAt: Scalars['DateTime']['output'];
+    id: Scalars['ID']['output'];
+    languageCode: LanguageCode;
+    name: Scalars['String']['output'];
+    updatedAt: Scalars['DateTime']['output'];
+};
+
 export enum AssetType {
     BINARY = 'BINARY',
     IMAGE = 'IMAGE',

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
schema-admin.json


Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
schema-shop.json


Някои файлове не бяха показани, защото твърде много файлове са промени