Просмотр исходного кода

feat(core): Add "slug" field to Collection entity

Relates to #335

BREAKING CHANGE: A new "slug" field has been added to the CollectionTranslation entity, requiring a DB migration. Also, when creating a new Collection via the `createCollection` mutation, each translation must include a slug.
Michael Bromley 5 лет назад
Родитель
Сommit
5b4d3db4c7
28 измененных файлов с 433 добавлено и 165 удалено
  1. 21 10
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 21 10
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  3. 4 0
      packages/common/src/generated-shop-types.ts
  4. 21 10
      packages/common/src/generated-types.ts
  5. 4 0
      packages/core/e2e/__snapshots__/collection.e2e-spec.ts.snap
  6. 132 18
      packages/core/e2e/collection.e2e-spec.ts
  7. 17 16
      packages/core/e2e/default-search-plugin.e2e-spec.ts
  8. 2 0
      packages/core/e2e/graphql/fragments.ts
  9. 23 12
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  10. 4 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  11. 9 2
      packages/core/e2e/shop-catalog.e2e-spec.ts
  12. 5 5
      packages/core/src/api/config/__snapshots__/graphql-custom-fields.spec.ts.snap
  13. 11 3
      packages/core/src/api/schema/admin-api/collection.api.graphql
  14. 2 0
      packages/core/src/api/schema/type/collection.type.graphql
  15. 1 0
      packages/core/src/data-import/providers/populator/populator.ts
  16. 1 0
      packages/core/src/data-import/types.ts
  17. 7 1
      packages/core/src/entity/collection/collection-translation.entity.ts
  18. 15 3
      packages/core/src/entity/collection/collection.entity.ts
  19. 70 0
      packages/core/src/service/helpers/slug-validator/slug-validator.ts
  20. 2 0
      packages/core/src/service/service.module.ts
  21. 25 20
      packages/core/src/service/services/collection.service.ts
  22. 12 43
      packages/core/src/service/services/product.service.ts
  23. 1 1
      packages/dev-server/dev-config.ts
  24. 1 1
      packages/elasticsearch-plugin/e2e/constants.js
  25. 1 0
      packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts
  26. 21 10
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  27. 0 0
      schema-admin.json
  28. 0 0
      schema-shop.json

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

@@ -226,6 +226,7 @@ export type Collection = Node & {
   updatedAt: Scalars['DateTime'];
   languageCode?: Maybe<LanguageCode>;
   name: Scalars['String'];
+  slug: Scalars['String'];
   breadcrumbs: Array<CollectionBreadcrumb>;
   position: Scalars['Int'];
   description: Scalars['String'];
@@ -256,6 +257,7 @@ export type CollectionFilterParameter = {
   updatedAt?: Maybe<DateOperators>;
   languageCode?: Maybe<StringOperators>;
   name?: Maybe<StringOperators>;
+  slug?: Maybe<StringOperators>;
   position?: Maybe<NumberOperators>;
   description?: Maybe<StringOperators>;
 };
@@ -278,6 +280,7 @@ export type CollectionSortParameter = {
   createdAt?: Maybe<SortOrder>;
   updatedAt?: Maybe<SortOrder>;
   name?: Maybe<SortOrder>;
+  slug?: Maybe<SortOrder>;
   position?: Maybe<SortOrder>;
   description?: Maybe<SortOrder>;
 };
@@ -289,17 +292,10 @@ export type CollectionTranslation = {
   updatedAt: Scalars['DateTime'];
   languageCode: LanguageCode;
   name: Scalars['String'];
+  slug: Scalars['String'];
   description: Scalars['String'];
 };
 
-export type CollectionTranslationInput = {
-  id?: Maybe<Scalars['ID']>;
-  languageCode: LanguageCode;
-  name?: Maybe<Scalars['String']>;
-  description?: Maybe<Scalars['String']>;
-  customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type ConfigArg = {
    __typename?: 'ConfigArg';
   name: Scalars['String'];
@@ -451,10 +447,17 @@ export type CreateCollectionInput = {
   assetIds?: Maybe<Array<Scalars['ID']>>;
   parentId?: Maybe<Scalars['ID']>;
   filters: Array<ConfigurableOperationInput>;
-  translations: Array<CollectionTranslationInput>;
+  translations: Array<CreateCollectionTranslationInput>;
   customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type CreateCollectionTranslationInput = {
+  languageCode: LanguageCode;
+  name: Scalars['String'];
+  slug: Scalars['String'];
+  description: Scalars['String'];
+};
+
 export type CreateCountryInput = {
   code: Scalars['String'];
   translations: Array<CountryTranslationInput>;
@@ -3574,10 +3577,18 @@ export type UpdateCollectionInput = {
   parentId?: Maybe<Scalars['ID']>;
   assetIds?: Maybe<Array<Scalars['ID']>>;
   filters?: Maybe<Array<ConfigurableOperationInput>>;
-  translations?: Maybe<Array<CollectionTranslationInput>>;
+  translations?: Maybe<Array<UpdateCollectionTranslationInput>>;
   customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type UpdateCollectionTranslationInput = {
+  id?: Maybe<Scalars['ID']>;
+  languageCode: LanguageCode;
+  name?: Maybe<Scalars['String']>;
+  slug?: Maybe<Scalars['String']>;
+  description?: Maybe<Scalars['String']>;
+};
+
 export type UpdateCountryInput = {
   id: Scalars['ID'];
   code?: Maybe<Scalars['String']>;

+ 21 - 10
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -226,6 +226,7 @@ export type Collection = Node & {
     updatedAt: Scalars['DateTime'];
     languageCode?: Maybe<LanguageCode>;
     name: Scalars['String'];
+    slug: Scalars['String'];
     breadcrumbs: Array<CollectionBreadcrumb>;
     position: Scalars['Int'];
     description: Scalars['String'];
@@ -255,6 +256,7 @@ export type CollectionFilterParameter = {
     updatedAt?: Maybe<DateOperators>;
     languageCode?: Maybe<StringOperators>;
     name?: Maybe<StringOperators>;
+    slug?: Maybe<StringOperators>;
     position?: Maybe<NumberOperators>;
     description?: Maybe<StringOperators>;
 };
@@ -277,6 +279,7 @@ export type CollectionSortParameter = {
     createdAt?: Maybe<SortOrder>;
     updatedAt?: Maybe<SortOrder>;
     name?: Maybe<SortOrder>;
+    slug?: Maybe<SortOrder>;
     position?: Maybe<SortOrder>;
     description?: Maybe<SortOrder>;
 };
@@ -288,17 +291,10 @@ export type CollectionTranslation = {
     updatedAt: Scalars['DateTime'];
     languageCode: LanguageCode;
     name: Scalars['String'];
+    slug: Scalars['String'];
     description: Scalars['String'];
 };
 
-export type CollectionTranslationInput = {
-    id?: Maybe<Scalars['ID']>;
-    languageCode: LanguageCode;
-    name?: Maybe<Scalars['String']>;
-    description?: Maybe<Scalars['String']>;
-    customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type ConfigArg = {
     __typename?: 'ConfigArg';
     name: Scalars['String'];
@@ -450,10 +446,17 @@ export type CreateCollectionInput = {
     assetIds?: Maybe<Array<Scalars['ID']>>;
     parentId?: Maybe<Scalars['ID']>;
     filters: Array<ConfigurableOperationInput>;
-    translations: Array<CollectionTranslationInput>;
+    translations: Array<CreateCollectionTranslationInput>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type CreateCollectionTranslationInput = {
+    languageCode: LanguageCode;
+    name: Scalars['String'];
+    slug: Scalars['String'];
+    description: Scalars['String'];
+};
+
 export type CreateCountryInput = {
     code: Scalars['String'];
     translations: Array<CountryTranslationInput>;
@@ -3406,10 +3409,18 @@ export type UpdateCollectionInput = {
     parentId?: Maybe<Scalars['ID']>;
     assetIds?: Maybe<Array<Scalars['ID']>>;
     filters?: Maybe<Array<ConfigurableOperationInput>>;
-    translations?: Maybe<Array<CollectionTranslationInput>>;
+    translations?: Maybe<Array<UpdateCollectionTranslationInput>>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type UpdateCollectionTranslationInput = {
+    id?: Maybe<Scalars['ID']>;
+    languageCode: LanguageCode;
+    name?: Maybe<Scalars['String']>;
+    slug?: Maybe<Scalars['String']>;
+    description?: Maybe<Scalars['String']>;
+};
+
 export type UpdateCountryInput = {
     id: Scalars['ID'];
     code?: Maybe<Scalars['String']>;

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

@@ -142,6 +142,7 @@ export type Collection = Node & {
     updatedAt: Scalars['DateTime'];
     languageCode?: Maybe<LanguageCode>;
     name: Scalars['String'];
+    slug: Scalars['String'];
     breadcrumbs: Array<CollectionBreadcrumb>;
     position: Scalars['Int'];
     description: Scalars['String'];
@@ -170,6 +171,7 @@ export type CollectionFilterParameter = {
     updatedAt?: Maybe<DateOperators>;
     languageCode?: Maybe<StringOperators>;
     name?: Maybe<StringOperators>;
+    slug?: Maybe<StringOperators>;
     position?: Maybe<NumberOperators>;
     description?: Maybe<StringOperators>;
 };
@@ -192,6 +194,7 @@ export type CollectionSortParameter = {
     createdAt?: Maybe<SortOrder>;
     updatedAt?: Maybe<SortOrder>;
     name?: Maybe<SortOrder>;
+    slug?: Maybe<SortOrder>;
     position?: Maybe<SortOrder>;
     description?: Maybe<SortOrder>;
 };
@@ -203,6 +206,7 @@ export type CollectionTranslation = {
     updatedAt: Scalars['DateTime'];
     languageCode: LanguageCode;
     name: Scalars['String'];
+    slug: Scalars['String'];
     description: Scalars['String'];
 };
 

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

@@ -225,6 +225,7 @@ export type Collection = Node & {
   updatedAt: Scalars['DateTime'];
   languageCode?: Maybe<LanguageCode>;
   name: Scalars['String'];
+  slug: Scalars['String'];
   breadcrumbs: Array<CollectionBreadcrumb>;
   position: Scalars['Int'];
   description: Scalars['String'];
@@ -255,6 +256,7 @@ export type CollectionFilterParameter = {
   updatedAt?: Maybe<DateOperators>;
   languageCode?: Maybe<StringOperators>;
   name?: Maybe<StringOperators>;
+  slug?: Maybe<StringOperators>;
   position?: Maybe<NumberOperators>;
   description?: Maybe<StringOperators>;
 };
@@ -277,6 +279,7 @@ export type CollectionSortParameter = {
   createdAt?: Maybe<SortOrder>;
   updatedAt?: Maybe<SortOrder>;
   name?: Maybe<SortOrder>;
+  slug?: Maybe<SortOrder>;
   position?: Maybe<SortOrder>;
   description?: Maybe<SortOrder>;
 };
@@ -288,17 +291,10 @@ export type CollectionTranslation = {
   updatedAt: Scalars['DateTime'];
   languageCode: LanguageCode;
   name: Scalars['String'];
+  slug: Scalars['String'];
   description: Scalars['String'];
 };
 
-export type CollectionTranslationInput = {
-  id?: Maybe<Scalars['ID']>;
-  languageCode: LanguageCode;
-  name?: Maybe<Scalars['String']>;
-  description?: Maybe<Scalars['String']>;
-  customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type ConfigArg = {
    __typename?: 'ConfigArg';
   name: Scalars['String'];
@@ -450,10 +446,17 @@ export type CreateCollectionInput = {
   assetIds?: Maybe<Array<Scalars['ID']>>;
   parentId?: Maybe<Scalars['ID']>;
   filters: Array<ConfigurableOperationInput>;
-  translations: Array<CollectionTranslationInput>;
+  translations: Array<CreateCollectionTranslationInput>;
   customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type CreateCollectionTranslationInput = {
+  languageCode: LanguageCode;
+  name: Scalars['String'];
+  slug: Scalars['String'];
+  description: Scalars['String'];
+};
+
 export type CreateCountryInput = {
   code: Scalars['String'];
   translations: Array<CountryTranslationInput>;
@@ -3526,10 +3529,18 @@ export type UpdateCollectionInput = {
   parentId?: Maybe<Scalars['ID']>;
   assetIds?: Maybe<Array<Scalars['ID']>>;
   filters?: Maybe<Array<ConfigurableOperationInput>>;
-  translations?: Maybe<Array<CollectionTranslationInput>>;
+  translations?: Maybe<Array<UpdateCollectionTranslationInput>>;
   customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type UpdateCollectionTranslationInput = {
+  id?: Maybe<Scalars['ID']>;
+  languageCode: LanguageCode;
+  name?: Maybe<Scalars['String']>;
+  slug?: Maybe<Scalars['String']>;
+  description?: Maybe<Scalars['String']>;
+};
+
 export type UpdateCountryInput = {
   id: Scalars['ID'];
   code?: Maybe<Scalars['String']>;

+ 4 - 0
packages/core/e2e/__snapshots__/collection.e2e-spec.ts.snap

@@ -58,12 +58,14 @@ Object {
     "id": "T_1",
     "name": "__root_collection__",
   },
+  "slug": "electronics",
   "translations": Array [
     Object {
       "description": "",
       "id": "T_3",
       "languageCode": "en",
       "name": "Electronics",
+      "slug": "electronics",
     },
   ],
 }
@@ -127,12 +129,14 @@ Object {
     "id": "T_4",
     "name": "Computers",
   },
+  "slug": "apple-stuff",
   "translations": Array [
     Object {
       "description": "Apple stuff ",
       "id": "T_5",
       "languageCode": "en",
       "name": "Pear",
+      "slug": "apple-stuff",
     },
   ],
 }

+ 132 - 18
packages/core/e2e/collection.e2e-spec.ts

@@ -128,7 +128,12 @@ describe('Collection resolver', () => {
                             },
                         ],
                         translations: [
-                            { languageCode: LanguageCode.en, name: 'Electronics', description: '' },
+                            {
+                                languageCode: LanguageCode.en,
+                                name: 'Electronics',
+                                description: '',
+                                slug: 'electronics',
+                            },
                         ],
                     },
                 },
@@ -145,7 +150,14 @@ describe('Collection resolver', () => {
                 {
                     input: {
                         parentId: electronicsCollection.id,
-                        translations: [{ languageCode: LanguageCode.en, name: 'Computers', description: '' }],
+                        translations: [
+                            {
+                                languageCode: LanguageCode.en,
+                                name: 'Computers',
+                                description: '',
+                                slug: 'computers',
+                            },
+                        ],
                         filters: [
                             {
                                 code: facetValueCollectionFilter.code,
@@ -176,7 +188,9 @@ describe('Collection resolver', () => {
                 {
                     input: {
                         parentId: computersCollection.id,
-                        translations: [{ languageCode: LanguageCode.en, name: 'Pear', description: '' }],
+                        translations: [
+                            { languageCode: LanguageCode.en, name: 'Pear', description: '', slug: 'pear' },
+                        ],
                         filters: [
                             {
                                 code: facetValueCollectionFilter.code,
@@ -200,6 +214,71 @@ describe('Collection resolver', () => {
             pearCollection = result.createCollection;
             expect(pearCollection.parent!.name).toBe(computersCollection.name);
         });
+
+        it('slug is normalized to be url-safe', async () => {
+            const { createCollection } = await adminClient.query<
+                CreateCollection.Mutation,
+                CreateCollection.Variables
+            >(CREATE_COLLECTION, {
+                input: {
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            name: 'Accessories',
+                            description: '',
+                            slug: 'Accessories!',
+                        },
+                        {
+                            languageCode: LanguageCode.de,
+                            name: 'Zubehör',
+                            description: '',
+                            slug: 'Zubehör!',
+                        },
+                    ],
+                    filters: [],
+                },
+            });
+
+            expect(createCollection.slug).toBe('accessories');
+            expect(createCollection.translations.find(t => t.languageCode === LanguageCode.en)?.slug).toBe(
+                'accessories',
+            );
+            expect(createCollection.translations.find(t => t.languageCode === LanguageCode.de)?.slug).toBe(
+                'zubehor',
+            );
+        });
+
+        it('create with duplicate slug is renamed to be unique', async () => {
+            const { createCollection } = await adminClient.query<
+                CreateCollection.Mutation,
+                CreateCollection.Variables
+            >(CREATE_COLLECTION, {
+                input: {
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            name: 'Accessories',
+                            description: '',
+                            slug: 'Accessories',
+                        },
+                        {
+                            languageCode: LanguageCode.de,
+                            name: 'Zubehör',
+                            description: '',
+                            slug: 'Zubehör',
+                        },
+                    ],
+                    filters: [],
+                },
+            });
+
+            expect(createCollection.translations.find(t => t.languageCode === LanguageCode.en)?.slug).toBe(
+                'accessories-2',
+            );
+            expect(createCollection.translations.find(t => t.languageCode === LanguageCode.de)?.slug).toBe(
+                'zubehor-2',
+            );
+        });
     });
 
     describe('updateCollection', () => {
@@ -212,7 +291,9 @@ describe('Collection resolver', () => {
                     id: pearCollection.id,
                     assetIds: [assets[1].id, assets[2].id],
                     featuredAssetId: assets[1].id,
-                    translations: [{ languageCode: LanguageCode.en, description: 'Apple stuff ' }],
+                    translations: [
+                        { languageCode: LanguageCode.en, description: 'Apple stuff ', slug: 'apple-stuff' },
+                    ],
                 },
             });
 
@@ -484,7 +565,12 @@ describe('Collection resolver', () => {
                             },
                         ],
                         translations: [
-                            { languageCode: LanguageCode.en, name: 'Delete Me Parent', description: '' },
+                            {
+                                languageCode: LanguageCode.en,
+                                name: 'Delete Me Parent',
+                                description: '',
+                                slug: 'delete-me-parent',
+                            },
                         ],
                         assetIds: ['T_1'],
                     },
@@ -498,7 +584,12 @@ describe('Collection resolver', () => {
                     input: {
                         filters: [],
                         translations: [
-                            { languageCode: LanguageCode.en, name: 'Delete Me Child', description: '' },
+                            {
+                                languageCode: LanguageCode.en,
+                                name: 'Delete Me Child',
+                                description: '',
+                                slug: 'delete-me-child',
+                            },
                         ],
                         parentId: collectionToDeleteParent.id,
                         assetIds: ['T_2'],
@@ -548,8 +639,8 @@ describe('Collection resolver', () => {
                 { id: 'T_3', name: 'Electronics' },
                 { id: 'T_4', name: 'Computers' },
                 { id: 'T_5', name: 'Pear' },
-                { id: 'T_6', name: 'Delete Me Parent' },
-                { id: 'T_7', name: 'Delete Me Child' },
+                { id: 'T_8', name: 'Delete Me Parent' },
+                { id: 'T_9', name: 'Delete Me Child' },
             ]);
         });
 
@@ -607,7 +698,9 @@ describe('Collection resolver', () => {
                 CreateCollectionSelectVariants.Variables
             >(CREATE_COLLECTION_SELECT_VARIANTS, {
                 input: {
-                    translations: [{ languageCode: LanguageCode.en, name: 'Empty', description: '' }],
+                    translations: [
+                        { languageCode: LanguageCode.en, name: 'Empty', description: '', slug: 'empty' },
+                    ],
                     filters: [],
                 } as CreateCollectionInput,
             });
@@ -682,7 +775,12 @@ describe('Collection resolver', () => {
                 >(CREATE_COLLECTION_SELECT_VARIANTS, {
                     input: {
                         translations: [
-                            { languageCode: LanguageCode.en, name: 'Photo AND Pear', description: '' },
+                            {
+                                languageCode: LanguageCode.en,
+                                name: 'Photo AND Pear',
+                                description: '',
+                                slug: 'photo-and-pear',
+                            },
                         ],
                         filters: [
                             {
@@ -724,7 +822,12 @@ describe('Collection resolver', () => {
                 >(CREATE_COLLECTION_SELECT_VARIANTS, {
                     input: {
                         translations: [
-                            { languageCode: LanguageCode.en, name: 'Photo OR Pear', description: '' },
+                            {
+                                languageCode: LanguageCode.en,
+                                name: 'Photo OR Pear',
+                                description: '',
+                                slug: 'photo-or-pear',
+                            },
                         ],
                         filters: [
                             {
@@ -781,6 +884,7 @@ describe('Collection resolver', () => {
                                 languageCode: LanguageCode.en,
                                 name: 'Bell OR Pear Computers',
                                 description: '',
+                                slug: 'bell-or-pear',
                             },
                         ],
                         filters: [
@@ -833,7 +937,12 @@ describe('Collection resolver', () => {
                 >(CREATE_COLLECTION, {
                     input: {
                         translations: [
-                            { languageCode: LanguageCode.en, name: `${operator} ${term}`, description: '' },
+                            {
+                                languageCode: LanguageCode.en,
+                                name: `${operator} ${term}`,
+                                description: '',
+                                slug: `${operator} ${term}`,
+                            },
                         ],
                         filters: [
                             {
@@ -1062,7 +1171,12 @@ describe('Collection resolver', () => {
                 input: {
                     parentId: electronicsCollection.id,
                     translations: [
-                        { languageCode: LanguageCode.en, name: 'pear electronics', description: '' },
+                        {
+                            languageCode: LanguageCode.en,
+                            name: 'pear electronics',
+                            description: '',
+                            slug: 'pear-electronics',
+                        },
                     ],
                     filters: [
                         {
@@ -1113,11 +1227,11 @@ describe('Collection resolver', () => {
             expect(result.products.items[0].collections).toEqual([
                 { id: 'T_3', name: 'Electronics' },
                 { id: 'T_5', name: 'Pear' },
-                { id: 'T_9', name: 'Photo AND Pear' },
-                { id: 'T_10', name: 'Photo OR Pear' },
-                { id: 'T_12', name: 'contains camera' },
-                { id: 'T_14', name: 'endsWith camera' },
-                { id: 'T_16', name: 'pear electronics' },
+                { id: 'T_11', name: 'Photo AND Pear' },
+                { id: 'T_12', name: 'Photo OR Pear' },
+                { id: 'T_14', name: 'contains camera' },
+                { id: 'T_16', name: 'endsWith camera' },
+                { id: 'T_18', name: 'pear electronics' },
             ]);
         });
     });

+ 17 - 16
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -11,7 +11,7 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import {
     AssignProductsToChannel,
@@ -116,7 +116,7 @@ describe('Default search plugin', () => {
                 },
             },
         );
-        expect(result.search.items.map((i) => i.productName)).toEqual([
+        expect(result.search.items.map(i => i.productName)).toEqual([
             'Camera Lens',
             'Instant Camera',
             'Slr Camera',
@@ -133,7 +133,7 @@ describe('Default search plugin', () => {
                 },
             },
         );
-        expect(result.search.items.map((i) => i.productName)).toEqual([
+        expect(result.search.items.map(i => i.productName)).toEqual([
             'Laptop',
             'Curvy Monitor',
             'Gaming PC',
@@ -153,7 +153,7 @@ describe('Default search plugin', () => {
                 },
             },
         );
-        expect(result.search.items.map((i) => i.productName)).toEqual([
+        expect(result.search.items.map(i => i.productName)).toEqual([
             'Spiky Cactus',
             'Orchid',
             'Bonsai Tree',
@@ -343,7 +343,7 @@ describe('Default search plugin', () => {
                     },
                 },
             );
-            expect(result.search.items.map((i) => i.productVariantId)).toEqual(['T_1', 'T_2', 'T_4']);
+            expect(result.search.items.map(i => i.productVariantId)).toEqual(['T_1', 'T_2', 'T_4']);
         });
 
         it('encodes collectionIds', async () => {
@@ -381,7 +381,7 @@ describe('Default search plugin', () => {
             it('updates index when ProductVariants are changed', async () => {
                 await awaitRunningJobs(adminClient);
                 const { search } = await doAdminSearchQuery({ term: 'drive', groupByProduct: false });
-                expect(search.items.map((i) => i.sku)).toEqual([
+                expect(search.items.map(i => i.sku)).toEqual([
                     'IHD455T1',
                     'IHD455T2',
                     'IHD455T3',
@@ -392,7 +392,7 @@ describe('Default search plugin', () => {
                 await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
                     UPDATE_PRODUCT_VARIANTS,
                     {
-                        input: search.items.map((i) => ({
+                        input: search.items.map(i => ({
                             id: i.productVariantId,
                             sku: i.sku + '_updated',
                         })),
@@ -405,7 +405,7 @@ describe('Default search plugin', () => {
                     groupByProduct: false,
                 });
 
-                expect(search2.items.map((i) => i.sku)).toEqual([
+                expect(search2.items.map(i => i.sku)).toEqual([
                     'IHD455T1_updated',
                     'IHD455T2_updated',
                     'IHD455T3_updated',
@@ -431,7 +431,7 @@ describe('Default search plugin', () => {
                     groupByProduct: false,
                 });
 
-                expect(search2.items.map((i) => i.sku)).toEqual([
+                expect(search2.items.map(i => i.sku)).toEqual([
                     'IHD455T2_updated',
                     'IHD455T3_updated',
                     'IHD455T4_updated',
@@ -448,7 +448,7 @@ describe('Default search plugin', () => {
                 });
                 await awaitRunningJobs(adminClient);
                 const result = await doAdminSearchQuery({ facetValueIds: ['T_2'], groupByProduct: true });
-                expect(result.search.items.map((i) => i.productName)).toEqual([
+                expect(result.search.items.map(i => i.productName)).toEqual([
                     'Curvy Monitor',
                     'Gaming PC',
                     'Hard Drive',
@@ -459,7 +459,7 @@ describe('Default search plugin', () => {
 
             it('updates index when a Product is deleted', async () => {
                 const { search } = await doAdminSearchQuery({ facetValueIds: ['T_2'], groupByProduct: true });
-                expect(search.items.map((i) => i.productId)).toEqual(['T_2', 'T_3', 'T_4', 'T_5', 'T_6']);
+                expect(search.items.map(i => i.productId)).toEqual(['T_2', 'T_3', 'T_4', 'T_5', 'T_6']);
                 await adminClient.query<DeleteProduct.Mutation, DeleteProduct.Variables>(DELETE_PRODUCT, {
                     id: 'T_5',
                 });
@@ -468,7 +468,7 @@ describe('Default search plugin', () => {
                     facetValueIds: ['T_2'],
                     groupByProduct: true,
                 });
-                expect(search2.items.map((i) => i.productId)).toEqual(['T_2', 'T_3', 'T_4', 'T_6']);
+                expect(search2.items.map(i => i.productId)).toEqual(['T_2', 'T_3', 'T_4', 'T_6']);
             });
 
             it('updates index when a Collection is changed', async () => {
@@ -502,7 +502,7 @@ describe('Default search plugin', () => {
                 await awaitRunningJobs(adminClient);
                 const result = await doAdminSearchQuery({ collectionId: 'T_2', groupByProduct: true });
 
-                expect(result.search.items.map((i) => i.productName)).toEqual([
+                expect(result.search.items.map(i => i.productName)).toEqual([
                     'Road Bike',
                     'Skipping Rope',
                     'Boxing Gloves',
@@ -524,6 +524,7 @@ describe('Default search plugin', () => {
                                 languageCode: LanguageCode.en,
                                 name: 'Photo',
                                 description: '',
+                                slug: 'photo',
                             },
                         ],
                         filters: [
@@ -552,7 +553,7 @@ describe('Default search plugin', () => {
                     collectionId: createCollection.id,
                     groupByProduct: true,
                 });
-                expect(result.search.items.map((i) => i.productName)).toEqual([
+                expect(result.search.items.map(i => i.productName)).toEqual([
                     'Instant Camera',
                     'Camera Lens',
                     'Tripod',
@@ -774,7 +775,7 @@ describe('Default search plugin', () => {
 
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
                 const { search } = await doAdminSearchQuery({ groupByProduct: true });
-                expect(search.items.map((i) => i.productId)).toEqual(['T_1', 'T_2']);
+                expect(search.items.map(i => i.productId)).toEqual(['T_1', 'T_2']);
             }, 10000);
 
             it('removing product from channel', async () => {
@@ -792,7 +793,7 @@ describe('Default search plugin', () => {
 
                 adminClient.setChannelToken(SECOND_CHANNEL_TOKEN);
                 const { search } = await doAdminSearchQuery({ groupByProduct: true });
-                expect(search.items.map((i) => i.productId)).toEqual(['T_1']);
+                expect(search.items.map(i => i.productId)).toEqual(['T_1']);
             }, 10000);
         });
     });

+ 2 - 0
packages/core/e2e/graphql/fragments.ts

@@ -160,6 +160,7 @@ export const COLLECTION_FRAGMENT = gql`
     fragment Collection on Collection {
         id
         name
+        slug
         description
         isPrivate
         languageCode
@@ -176,6 +177,7 @@ export const COLLECTION_FRAGMENT = gql`
             id
             languageCode
             name
+            slug
             description
         }
         parent {

+ 23 - 12
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -226,6 +226,7 @@ export type Collection = Node & {
     updatedAt: Scalars['DateTime'];
     languageCode?: Maybe<LanguageCode>;
     name: Scalars['String'];
+    slug: Scalars['String'];
     breadcrumbs: Array<CollectionBreadcrumb>;
     position: Scalars['Int'];
     description: Scalars['String'];
@@ -255,6 +256,7 @@ export type CollectionFilterParameter = {
     updatedAt?: Maybe<DateOperators>;
     languageCode?: Maybe<StringOperators>;
     name?: Maybe<StringOperators>;
+    slug?: Maybe<StringOperators>;
     position?: Maybe<NumberOperators>;
     description?: Maybe<StringOperators>;
 };
@@ -277,6 +279,7 @@ export type CollectionSortParameter = {
     createdAt?: Maybe<SortOrder>;
     updatedAt?: Maybe<SortOrder>;
     name?: Maybe<SortOrder>;
+    slug?: Maybe<SortOrder>;
     position?: Maybe<SortOrder>;
     description?: Maybe<SortOrder>;
 };
@@ -288,17 +291,10 @@ export type CollectionTranslation = {
     updatedAt: Scalars['DateTime'];
     languageCode: LanguageCode;
     name: Scalars['String'];
+    slug: Scalars['String'];
     description: Scalars['String'];
 };
 
-export type CollectionTranslationInput = {
-    id?: Maybe<Scalars['ID']>;
-    languageCode: LanguageCode;
-    name?: Maybe<Scalars['String']>;
-    description?: Maybe<Scalars['String']>;
-    customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type ConfigArg = {
     __typename?: 'ConfigArg';
     name: Scalars['String'];
@@ -450,10 +446,17 @@ export type CreateCollectionInput = {
     assetIds?: Maybe<Array<Scalars['ID']>>;
     parentId?: Maybe<Scalars['ID']>;
     filters: Array<ConfigurableOperationInput>;
-    translations: Array<CollectionTranslationInput>;
+    translations: Array<CreateCollectionTranslationInput>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type CreateCollectionTranslationInput = {
+    languageCode: LanguageCode;
+    name: Scalars['String'];
+    slug: Scalars['String'];
+    description: Scalars['String'];
+};
+
 export type CreateCountryInput = {
     code: Scalars['String'];
     translations: Array<CountryTranslationInput>;
@@ -3406,10 +3409,18 @@ export type UpdateCollectionInput = {
     parentId?: Maybe<Scalars['ID']>;
     assetIds?: Maybe<Array<Scalars['ID']>>;
     filters?: Maybe<Array<ConfigurableOperationInput>>;
-    translations?: Maybe<Array<CollectionTranslationInput>>;
+    translations?: Maybe<Array<UpdateCollectionTranslationInput>>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type UpdateCollectionTranslationInput = {
+    id?: Maybe<Scalars['ID']>;
+    languageCode: LanguageCode;
+    name?: Maybe<Scalars['String']>;
+    slug?: Maybe<Scalars['String']>;
+    description?: Maybe<Scalars['String']>;
+};
+
 export type UpdateCountryInput = {
     id: Scalars['ID'];
     code?: Maybe<Scalars['String']>;
@@ -4375,7 +4386,7 @@ export type ConfigurableOperationFragment = { __typename?: 'ConfigurableOperatio
 
 export type CollectionFragment = { __typename?: 'Collection' } & Pick<
     Collection,
-    'id' | 'name' | 'description' | 'isPrivate' | 'languageCode'
+    'id' | 'name' | 'slug' | 'description' | 'isPrivate' | 'languageCode'
 > & {
         featuredAsset?: Maybe<{ __typename?: 'Asset' } & AssetFragment>;
         assets: Array<{ __typename?: 'Asset' } & AssetFragment>;
@@ -4383,7 +4394,7 @@ export type CollectionFragment = { __typename?: 'Collection' } & Pick<
         translations: Array<
             { __typename?: 'CollectionTranslation' } & Pick<
                 CollectionTranslation,
-                'id' | 'languageCode' | 'name' | 'description'
+                'id' | 'languageCode' | 'name' | 'slug' | 'description'
             >
         >;
         parent?: Maybe<{ __typename?: 'Collection' } & Pick<Collection, 'id' | 'name'>>;

+ 4 - 0
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -142,6 +142,7 @@ export type Collection = Node & {
     updatedAt: Scalars['DateTime'];
     languageCode?: Maybe<LanguageCode>;
     name: Scalars['String'];
+    slug: Scalars['String'];
     breadcrumbs: Array<CollectionBreadcrumb>;
     position: Scalars['Int'];
     description: Scalars['String'];
@@ -170,6 +171,7 @@ export type CollectionFilterParameter = {
     updatedAt?: Maybe<DateOperators>;
     languageCode?: Maybe<StringOperators>;
     name?: Maybe<StringOperators>;
+    slug?: Maybe<StringOperators>;
     position?: Maybe<NumberOperators>;
     description?: Maybe<StringOperators>;
 };
@@ -192,6 +194,7 @@ export type CollectionSortParameter = {
     createdAt?: Maybe<SortOrder>;
     updatedAt?: Maybe<SortOrder>;
     name?: Maybe<SortOrder>;
+    slug?: Maybe<SortOrder>;
     position?: Maybe<SortOrder>;
     description?: Maybe<SortOrder>;
 };
@@ -203,6 +206,7 @@ export type CollectionTranslation = {
     updatedAt: Scalars['DateTime'];
     languageCode: LanguageCode;
     name: Scalars['String'];
+    slug: Scalars['String'];
     description: Scalars['String'];
 };
 

+ 9 - 2
packages/core/e2e/shop-catalog.e2e-spec.ts

@@ -5,7 +5,7 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import {
     CreateCollection,
@@ -258,7 +258,14 @@ describe('Shop catalog', () => {
                             ],
                         },
                     ],
-                    translations: [{ languageCode: LanguageCode.en, name: 'My Collection', description: '' }],
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            name: 'My Collection',
+                            description: '',
+                            slug: 'my-collection',
+                        },
+                    ],
                 },
             });
             collection = createCollection;

+ 5 - 5
packages/core/src/api/config/__snapshots__/graphql-custom-fields.spec.ts.snap

@@ -146,13 +146,13 @@ type ProductTranslationCustomFields {
   shortName: String
 }
 
-input ProductTranslationCustomFieldsInput {
-  shortName: String
-}
-
 input ProductTranslationInput {
   id: ID
-  customFields: ProductTranslationCustomFieldsInput
+  customFields: ProductTranslationInputCustomFields
+}
+
+input ProductTranslationInputCustomFields {
+  shortName: String
 }
 "
 `;

+ 11 - 3
packages/core/src/api/schema/admin-api/collection.api.graphql

@@ -31,10 +31,18 @@ input MoveCollectionInput {
     index: Int!
 }
 
-input CollectionTranslationInput {
+input CreateCollectionTranslationInput {
+    languageCode: LanguageCode!
+    name: String!
+    slug: String!
+    description: String!
+}
+
+input UpdateCollectionTranslationInput {
     id: ID
     languageCode: LanguageCode!
     name: String
+    slug: String
     description: String
 }
 
@@ -44,7 +52,7 @@ input CreateCollectionInput {
     assetIds: [ID!]
     parentId: ID
     filters: [ConfigurableOperationInput!]!
-    translations: [CollectionTranslationInput!]!
+    translations: [CreateCollectionTranslationInput!]!
 }
 
 input UpdateCollectionInput {
@@ -54,5 +62,5 @@ input UpdateCollectionInput {
     parentId: ID
     assetIds: [ID!]
     filters: [ConfigurableOperationInput!]
-    translations: [CollectionTranslationInput!]
+    translations: [UpdateCollectionTranslationInput!]
 }

+ 2 - 0
packages/core/src/api/schema/type/collection.type.graphql

@@ -4,6 +4,7 @@ type Collection implements Node {
     updatedAt: DateTime!
     languageCode: LanguageCode
     name: String!
+    slug: String!
     breadcrumbs: [CollectionBreadcrumb!]!
     position: Int!
     description: String!
@@ -27,6 +28,7 @@ type CollectionTranslation {
     updatedAt: DateTime!
     languageCode: LanguageCode!
     name: String!
+    slug: String!
     description: String!
 }
 

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

@@ -67,6 +67,7 @@ export class Populator {
                         languageCode: ctx.languageCode,
                         name: collectionDef.name,
                         description: collectionDef.description || '',
+                        slug: collectionDef.slug ?? collectionDef.name,
                     },
                 ],
                 isPrivate: collectionDef.private || false,

+ 1 - 0
packages/core/src/data-import/types.ts

@@ -23,6 +23,7 @@ export type CollectionFilterDefinition = FacetValueCollectionFilterDefinition;
 export interface CollectionDefinition {
     name: string;
     description?: string;
+    slug?: string;
     private?: boolean;
     filters?: CollectionFilterDefinition[];
     parentName?: string;

+ 7 - 1
packages/core/src/entity/collection/collection-translation.entity.ts

@@ -19,9 +19,15 @@ export class CollectionTranslation extends VendureEntity implements Translation<
 
     @Column() name: string;
 
+    @Column() slug: string;
+
     @Column('text') description: string;
 
-    @ManyToOne(type => Collection, base => base.translations, { onDelete: 'CASCADE' })
+    @ManyToOne(
+        type => Collection,
+        base => base.translations,
+        { onDelete: 'CASCADE' },
+    )
     base: Collection;
 
     @Column(type => CustomCollectionFieldsTranslation)

+ 15 - 3
packages/core/src/entity/collection/collection.entity.ts

@@ -54,18 +54,30 @@ export class Collection extends VendureEntity
 
     description: LocaleString;
 
-    @OneToMany(type => CollectionTranslation, translation => translation.base, { eager: true })
+    slug: LocaleString;
+
+    @OneToMany(
+        type => CollectionTranslation,
+        translation => translation.base,
+        { eager: true },
+    )
     translations: Array<Translation<Collection>>;
 
     @ManyToOne(type => Asset)
     featuredAsset: Asset;
 
-    @OneToMany(type => CollectionAsset, collectionAsset => collectionAsset.collection)
+    @OneToMany(
+        type => CollectionAsset,
+        collectionAsset => collectionAsset.collection,
+    )
     assets: CollectionAsset[];
 
     @Column('simple-json') filters: ConfigurableOperation[];
 
-    @ManyToMany(type => ProductVariant, productVariant => productVariant.collections)
+    @ManyToMany(
+        type => ProductVariant,
+        productVariant => productVariant.collections,
+    )
     @JoinTable()
     productVariants: ProductVariant[];
 

+ 70 - 0
packages/core/src/service/helpers/slug-validator/slug-validator.ts

@@ -0,0 +1,70 @@
+import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { normalizeString } from '@vendure/common/lib/normalize-string';
+import { ID, Type } from '@vendure/common/lib/shared-types';
+import { Connection } from 'typeorm';
+
+import { VendureEntity } from '../../../entity/base/base.entity';
+
+export type InputWithSlug = {
+    id?: ID | null;
+    translations?: Array<{
+        id?: ID | null;
+        languageCode: LanguageCode;
+        slug?: string | null;
+    }> | null;
+};
+
+export type TranslationEntity = VendureEntity & {
+    id: ID;
+    languageCode: LanguageCode;
+    slug: string;
+};
+
+@Injectable()
+export class SlugValidator {
+    constructor(@InjectConnection() private connection: Connection) {}
+
+    /**
+     * Normalizes the slug to be URL-safe, and ensures it is unique for the given languageCode.
+     * Mutates the input.
+     */
+    async validateSlugs<T extends InputWithSlug, E extends TranslationEntity>(
+        input: T,
+        translationEntity: Type<E>,
+    ): Promise<T> {
+        if (input.translations) {
+            for (const t of input.translations) {
+                if (t.slug) {
+                    t.slug = normalizeString(t.slug, '-');
+                    let match: E | undefined;
+                    let suffix = 1;
+                    const alreadySuffixed = /-\d+$/;
+                    do {
+                        const qb = this.connection
+                            .getRepository(translationEntity)
+                            .createQueryBuilder('translation')
+                            .where(`translation.slug = :slug`, { slug: t.slug })
+                            .andWhere(`translation.languageCode = :languageCode`, {
+                                languageCode: t.languageCode,
+                            });
+                        if (input.id) {
+                            qb.andWhere(`translation.base != :id`, { id: input.id });
+                        }
+                        match = await qb.getOne();
+                        if (match) {
+                            suffix++;
+                            if (alreadySuffixed.test(t.slug)) {
+                                t.slug = t.slug.replace(alreadySuffixed, `-${suffix}`);
+                            } else {
+                                t.slug = `${t.slug}-${suffix}`;
+                            }
+                        }
+                    } while (match);
+                }
+            }
+        }
+        return input;
+    }
+}

+ 2 - 0
packages/core/src/service/service.module.ts

@@ -18,6 +18,7 @@ import { PaymentStateMachine } from './helpers/payment-state-machine/payment-sta
 import { RefundStateMachine } from './helpers/refund-state-machine/refund-state-machine';
 import { ShippingCalculator } from './helpers/shipping-calculator/shipping-calculator';
 import { ShippingConfiguration } from './helpers/shipping-configuration/shipping-configuration';
+import { SlugValidator } from './helpers/slug-validator/slug-validator';
 import { TaxCalculator } from './helpers/tax-calculator/tax-calculator';
 import { TranslatableSaver } from './helpers/translatable-saver/translatable-saver';
 import { VerificationTokenGenerator } from './helpers/verification-token-generator/verification-token-generator';
@@ -94,6 +95,7 @@ const helpers = [
     VerificationTokenGenerator,
     RefundStateMachine,
     ShippingConfiguration,
+    SlugValidator,
 ];
 
 const workerControllers = [CollectionController, TaxRateController];

+ 25 - 20
packages/core/src/service/services/collection.service.ts

@@ -37,6 +37,7 @@ import { JobQueue } from '../../job-queue/job-queue';
 import { JobQueueService } from '../../job-queue/job-queue.service';
 import { WorkerService } from '../../worker/worker.service';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
+import { SlugValidator } from '../helpers/slug-validator/slug-validator';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { findOneInChannel } from '../helpers/utils/channel-aware-orm-utils';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
@@ -63,6 +64,7 @@ export class CollectionService implements OnModuleInit {
         private workerService: WorkerService,
         private jobQueueService: JobQueueService,
         private configService: ConfigService,
+        private slugValidator: SlugValidator,
     ) {}
 
     onModuleInit() {
@@ -71,18 +73,18 @@ export class CollectionService implements OnModuleInit {
 
         merge(productEvents$, variantEvents$)
             .pipe(debounceTime(50))
-            .subscribe(async (event) => {
+            .subscribe(async event => {
                 const collections = await this.connection.getRepository(Collection).find();
                 this.applyFiltersQueue.add({
                     ctx: event.ctx.serialize(),
-                    collectionIds: collections.map((c) => c.id),
+                    collectionIds: collections.map(c => c.id),
                 });
             });
 
         this.applyFiltersQueue = this.jobQueueService.createQueue({
             name: 'apply-collection-filters',
             concurrency: 1,
-            process: async (job) => {
+            process: async job => {
                 const collections = await this.connection
                     .getRepository(Collection)
                     .findByIds(job.data.collectionIds);
@@ -106,7 +108,7 @@ export class CollectionService implements OnModuleInit {
             })
             .getManyAndCount()
             .then(async ([collections, totalItems]) => {
-                const items = collections.map((collection) =>
+                const items = collections.map(collection =>
                     translateDeep(collection, ctx.languageCode, ['parent']),
                 );
                 return {
@@ -128,7 +130,7 @@ export class CollectionService implements OnModuleInit {
     }
 
     getAvailableFilters(ctx: RequestContext): ConfigurableOperationDefinition[] {
-        return this.configService.catalogOptions.collectionFilters.map((x) =>
+        return this.configService.catalogOptions.collectionFilters.map(x =>
             configurableDefToOperation(ctx, x),
         );
     }
@@ -139,7 +141,7 @@ export class CollectionService implements OnModuleInit {
             .createQueryBuilder('collection')
             .leftJoinAndSelect('collection.translations', 'translation')
             .where(
-                (qb) =>
+                qb =>
                     `collection.id = ${qb
                         .subQuery()
                         .select('child.parentId')
@@ -188,7 +190,7 @@ export class CollectionService implements OnModuleInit {
         }
         const result = await qb.getMany();
 
-        return result.map((collection) => translateDeep(collection, ctx.languageCode));
+        return result.map(collection => translateDeep(collection, ctx.languageCode));
     }
 
     /**
@@ -214,7 +216,7 @@ export class CollectionService implements OnModuleInit {
         };
 
         const descendants = await getChildren(rootId);
-        return descendants.map((c) => translateDeep(c, ctx.languageCode));
+        return descendants.map(c => translateDeep(c, ctx.languageCode));
     }
 
     /**
@@ -246,18 +248,19 @@ export class CollectionService implements OnModuleInit {
 
         return this.connection
             .getRepository(Collection)
-            .findByIds(ancestors.map((c) => c.id))
-            .then((categories) => {
-                return ctx ? categories.map((c) => translateDeep(c, ctx.languageCode)) : categories;
+            .findByIds(ancestors.map(c => c.id))
+            .then(categories => {
+                return ctx ? categories.map(c => translateDeep(c, ctx.languageCode)) : categories;
             });
     }
 
     async create(ctx: RequestContext, input: CreateCollectionInput): Promise<Translated<Collection>> {
+        await this.slugValidator.validateSlugs(input, CollectionTranslation);
         const collection = await this.translatableSaver.create({
             input,
             entityType: Collection,
             translationType: CollectionTranslation,
-            beforeSave: async (coll) => {
+            beforeSave: async coll => {
                 await this.channelService.assignToCurrentChannel(coll, ctx);
                 const parent = await this.getParentCollection(ctx, input.parentId);
                 if (parent) {
@@ -277,11 +280,12 @@ export class CollectionService implements OnModuleInit {
     }
 
     async update(ctx: RequestContext, input: UpdateCollectionInput): Promise<Translated<Collection>> {
+        await this.slugValidator.validateSlugs(input, CollectionTranslation);
         const collection = await this.translatableSaver.update({
             input,
             entityType: Collection,
             translationType: CollectionTranslation,
-            beforeSave: async (coll) => {
+            beforeSave: async coll => {
                 if (input.filters) {
                     coll.filters = this.getCollectionFiltersFromInput(input);
                 }
@@ -325,7 +329,7 @@ export class CollectionService implements OnModuleInit {
 
         if (
             idsAreEqual(input.parentId, target.id) ||
-            descendants.some((cat) => idsAreEqual(input.parentId, cat.id))
+            descendants.some(cat => idsAreEqual(input.parentId, cat.id))
         ) {
             throw new IllegalOperationError(`error.cannot-move-collection-into-self`);
         }
@@ -382,13 +386,13 @@ export class CollectionService implements OnModuleInit {
         collections: Collection[],
         job: Job<ApplyCollectionFiletersJobData>,
     ): Promise<void> {
-        const collectionIds = collections.map((c) => c.id);
+        const collectionIds = collections.map(c => c.id);
         const requestContext = RequestContext.deserialize(ctx);
 
         this.workerService.send(new ApplyCollectionFiltersMessage({ collectionIds })).subscribe({
             next: ({ total, completed, duration, collectionId, affectedVariantIds }) => {
                 const progress = Math.ceil((completed / total) * 100);
-                const collection = collections.find((c) => idsAreEqual(c.id, collectionId));
+                const collection = collections.find(c => idsAreEqual(c.id, collectionId));
                 if (collection) {
                     this.eventBus.publish(
                         new CollectionModificationEvent(requestContext, collection, affectedVariantIds),
@@ -399,7 +403,7 @@ export class CollectionService implements OnModuleInit {
             complete: () => {
                 job.complete();
             },
-            error: (err) => {
+            error: err => {
                 Logger.error(err);
                 job.fail(err);
             },
@@ -411,14 +415,14 @@ export class CollectionService implements OnModuleInit {
      */
     async getCollectionProductVariantIds(collection: Collection): Promise<ID[]> {
         if (collection.productVariants) {
-            return collection.productVariants.map((v) => v.id);
+            return collection.productVariants.map(v => v.id);
         } else {
             const productVariants = await this.connection
                 .getRepository(ProductVariant)
                 .createQueryBuilder('variant')
                 .innerJoin('variant.collections', 'collection', 'collection.id = :id', { id: collection.id })
                 .getMany();
-            return productVariants.map((v) => v.id);
+            return productVariants.map(v => v.id);
         }
     }
 
@@ -480,6 +484,7 @@ export class CollectionService implements OnModuleInit {
                 languageCode: this.configService.defaultLanguageCode,
                 name: ROOT_COLLECTION_NAME,
                 description: 'The root of the Collection tree.',
+                slug: ROOT_COLLECTION_NAME,
             }),
         );
 
@@ -497,7 +502,7 @@ export class CollectionService implements OnModuleInit {
     }
 
     private getFilterByCode(code: string): CollectionFilter<any> {
-        const match = this.configService.catalogOptions.collectionFilters.find((a) => a.code === code);
+        const match = this.configService.catalogOptions.collectionFilters.find(a => a.code === code);
         if (!match) {
             throw new UserInputError(`error.adjustment-operation-with-code-not-found`, { code });
         }

+ 12 - 43
packages/core/src/service/services/product.service.ts

@@ -9,7 +9,6 @@ import {
     RemoveProductsFromChannelInput,
     UpdateProductInput,
 } from '@vendure/common/lib/generated-types';
-import { normalizeString } from '@vendure/common/lib/normalize-string';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { Connection } from 'typeorm';
 
@@ -26,6 +25,7 @@ import { EventBus } from '../../event-bus/event-bus';
 import { ProductChannelEvent } from '../../event-bus/events/product-channel-event';
 import { ProductEvent } from '../../event-bus/events/product-event';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
+import { SlugValidator } from '../helpers/slug-validator/slug-validator';
 import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
 import { findByIdsInChannel, findOneInChannel } from '../helpers/utils/channel-aware-orm-utils';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
@@ -55,6 +55,7 @@ export class ProductService {
         private listQueryBuilder: ListQueryBuilder,
         private translatableSaver: TranslatableSaver,
         private eventBus: EventBus,
+        private slugValidator: SlugValidator,
     ) {}
 
     async findAll(
@@ -123,7 +124,7 @@ export class ProductService {
     }
 
     async create(ctx: RequestContext, input: CreateProductInput): Promise<Translated<Product>> {
-        await this.validateSlugs(input);
+        await this.slugValidator.validateSlugs(input, ProductTranslation);
         const product = await this.translatableSaver.create({
             input,
             entityType: Product,
@@ -143,7 +144,7 @@ export class ProductService {
 
     async update(ctx: RequestContext, input: UpdateProductInput): Promise<Translated<Product>> {
         await getEntityOrThrow(this.connection, Product, input.id);
-        await this.validateSlugs(input);
+        await this.slugValidator.validateSlugs(input, ProductTranslation);
         const product = await this.translatableSaver.update({
             input,
             entityType: Product,
@@ -199,7 +200,10 @@ export class ProductService {
             }
             this.eventBus.publish(new ProductChannelEvent(ctx, product, input.channelId, 'assigned'));
         }
-        return this.findByIds(ctx, productsWithVariants.map(p => p.id));
+        return this.findByIds(
+            ctx,
+            productsWithVariants.map(p => p.id),
+        );
     }
 
     async removeProductsFromChannel(
@@ -222,7 +226,10 @@ export class ProductService {
             await this.channelService.removeFromChannels(Product, product.id, [input.channelId]);
             this.eventBus.publish(new ProductChannelEvent(ctx, product, input.channelId, 'removed'));
         }
-        return this.findByIds(ctx, products.map(p => p.id));
+        return this.findByIds(
+            ctx,
+            products.map(p => p.id),
+        );
     }
 
     async addOptionGroupToProduct(
@@ -278,42 +285,4 @@ export class ProductService {
         }
         return product;
     }
-
-    /**
-     * Normalizes the slug to be URL-safe, and ensures it is unique for the given languageCode.
-     */
-    private async validateSlugs<T extends CreateProductInput | UpdateProductInput>(input: T): Promise<T> {
-        if (input.translations) {
-            for (const t of input.translations) {
-                if (t.slug) {
-                    t.slug = normalizeString(t.slug, '-');
-                    let match: ProductTranslation | undefined;
-                    let suffix = 1;
-                    const alreadySuffixed = /-\d+$/;
-                    do {
-                        const qb = this.connection
-                            .getRepository(ProductTranslation)
-                            .createQueryBuilder('translation')
-                            .where(`translation.slug = :slug`, { slug: t.slug })
-                            .andWhere(`translation.languageCode = :languageCode`, {
-                                languageCode: t.languageCode,
-                            });
-                        if ((input as UpdateProductInput).id) {
-                            qb.andWhere(`translation.base != :id`, { id: (input as UpdateProductInput).id });
-                        }
-                        match = await qb.getOne();
-                        if (match) {
-                            suffix++;
-                            if (alreadySuffixed.test(t.slug)) {
-                                t.slug = t.slug.replace(alreadySuffixed, `-${suffix}`);
-                            } else {
-                                t.slug = `${t.slug}-${suffix}`;
-                            }
-                        }
-                    } while (match);
-                }
-            }
-        }
-        return input;
-    }
 }

+ 1 - 1
packages/dev-server/dev-config.ts

@@ -123,7 +123,7 @@ function getDbConfig(): ConnectionOptions {
         default:
             console.log('Using mysql connection');
             return {
-                synchronize: false,
+                synchronize: true,
                 type: 'mysql',
                 host: '127.0.0.1',
                 port: 3306,

+ 1 - 1
packages/elasticsearch-plugin/e2e/constants.js

@@ -1,4 +1,4 @@
-const elasticsearchHost = process.env.CI ? 'http://127.0.0.1' : 'http://192.168.99.100';
+const elasticsearchHost = 'http://127.0.0.1';
 const elasticsearchPort = process.env.CI ? +(process.env.E2E_ELASTIC_PORT || 9200) : 9200;
 
 module.exports = {

+ 1 - 0
packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts

@@ -565,6 +565,7 @@ describe('Elasticsearch plugin', () => {
                                 languageCode: LanguageCode.en,
                                 name: 'Photo',
                                 description: '',
+                                slug: 'photo',
                             },
                         ],
                         filters: [

+ 21 - 10
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -226,6 +226,7 @@ export type Collection = Node & {
     updatedAt: Scalars['DateTime'];
     languageCode?: Maybe<LanguageCode>;
     name: Scalars['String'];
+    slug: Scalars['String'];
     breadcrumbs: Array<CollectionBreadcrumb>;
     position: Scalars['Int'];
     description: Scalars['String'];
@@ -255,6 +256,7 @@ export type CollectionFilterParameter = {
     updatedAt?: Maybe<DateOperators>;
     languageCode?: Maybe<StringOperators>;
     name?: Maybe<StringOperators>;
+    slug?: Maybe<StringOperators>;
     position?: Maybe<NumberOperators>;
     description?: Maybe<StringOperators>;
 };
@@ -277,6 +279,7 @@ export type CollectionSortParameter = {
     createdAt?: Maybe<SortOrder>;
     updatedAt?: Maybe<SortOrder>;
     name?: Maybe<SortOrder>;
+    slug?: Maybe<SortOrder>;
     position?: Maybe<SortOrder>;
     description?: Maybe<SortOrder>;
 };
@@ -288,17 +291,10 @@ export type CollectionTranslation = {
     updatedAt: Scalars['DateTime'];
     languageCode: LanguageCode;
     name: Scalars['String'];
+    slug: Scalars['String'];
     description: Scalars['String'];
 };
 
-export type CollectionTranslationInput = {
-    id?: Maybe<Scalars['ID']>;
-    languageCode: LanguageCode;
-    name?: Maybe<Scalars['String']>;
-    description?: Maybe<Scalars['String']>;
-    customFields?: Maybe<Scalars['JSON']>;
-};
-
 export type ConfigArg = {
     __typename?: 'ConfigArg';
     name: Scalars['String'];
@@ -450,10 +446,17 @@ export type CreateCollectionInput = {
     assetIds?: Maybe<Array<Scalars['ID']>>;
     parentId?: Maybe<Scalars['ID']>;
     filters: Array<ConfigurableOperationInput>;
-    translations: Array<CollectionTranslationInput>;
+    translations: Array<CreateCollectionTranslationInput>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type CreateCollectionTranslationInput = {
+    languageCode: LanguageCode;
+    name: Scalars['String'];
+    slug: Scalars['String'];
+    description: Scalars['String'];
+};
+
 export type CreateCountryInput = {
     code: Scalars['String'];
     translations: Array<CountryTranslationInput>;
@@ -3406,10 +3409,18 @@ export type UpdateCollectionInput = {
     parentId?: Maybe<Scalars['ID']>;
     assetIds?: Maybe<Array<Scalars['ID']>>;
     filters?: Maybe<Array<ConfigurableOperationInput>>;
-    translations?: Maybe<Array<CollectionTranslationInput>>;
+    translations?: Maybe<Array<UpdateCollectionTranslationInput>>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type UpdateCollectionTranslationInput = {
+    id?: Maybe<Scalars['ID']>;
+    languageCode: LanguageCode;
+    name?: Maybe<Scalars['String']>;
+    slug?: Maybe<Scalars['String']>;
+    description?: Maybe<Scalars['String']>;
+};
+
 export type UpdateCountryInput = {
     id: Scalars['ID'];
     code?: Maybe<Scalars['String']>;

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
schema-admin.json


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
schema-shop.json


Некоторые файлы не были показаны из-за большого количества измененных файлов