Переглянути джерело

feat(core, dashboard): Slug strategy and slug input (#3832)

David Höck 3 місяців тому
батько
коміт
ab3003b052

+ 42 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -12435,6 +12435,12 @@ export type GetCustomerIdsQueryVariables = Exact<{ [key: string]: never }>;
 
 export type GetCustomerIdsQuery = { customers: { items: Array<{ id: string }> } };
 
+export type SlugForEntityQueryVariables = Exact<{
+    input: SlugForEntityInput;
+}>;
+
+export type SlugForEntityQuery = { slugForEntity: string };
+
 export type StockLocationFragment = { id: string; name: string; description: string };
 
 export type GetStockLocationQueryVariables = Exact<{
@@ -38881,6 +38887,42 @@ export const GetCustomerIdsDocument = {
         },
     ],
 } as unknown as DocumentNode<GetCustomerIdsQuery, GetCustomerIdsQueryVariables>;
+export const SlugForEntityDocument = {
+    kind: 'Document',
+    definitions: [
+        {
+            kind: 'OperationDefinition',
+            operation: 'query',
+            name: { kind: 'Name', value: 'SlugForEntity' },
+            variableDefinitions: [
+                {
+                    kind: 'VariableDefinition',
+                    variable: { kind: 'Variable', name: { kind: 'Name', value: 'input' } },
+                    type: {
+                        kind: 'NonNullType',
+                        type: { kind: 'NamedType', name: { kind: 'Name', value: 'SlugForEntityInput' } },
+                    },
+                },
+            ],
+            selectionSet: {
+                kind: 'SelectionSet',
+                selections: [
+                    {
+                        kind: 'Field',
+                        name: { kind: 'Name', value: 'slugForEntity' },
+                        arguments: [
+                            {
+                                kind: 'Argument',
+                                name: { kind: 'Name', value: 'input' },
+                                value: { kind: 'Variable', name: { kind: 'Name', value: 'input' } },
+                            },
+                        ],
+                    },
+                ],
+            },
+        },
+    ],
+} as unknown as DocumentNode<SlugForEntityQuery, SlugForEntityQueryVariables>;
 export const GetStockLocationDocument = {
     kind: 'Document',
     definitions: [

+ 724 - 0
packages/core/e2e/slug.e2e-spec.ts

@@ -0,0 +1,724 @@
+import { createTestEnvironment } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from '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 { LanguageCode } from './graphql/generated-e2e-admin-types';
+import { CREATE_COLLECTION, CREATE_PRODUCT } from './graphql/shared-definitions';
+
+describe('Slug generation', () => {
+    const { server, adminClient } = createTestEnvironment(testConfig());
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('slugForEntity query', () => {
+        describe('basic slug generation', () => {
+            it('generates a simple slug', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Test Product',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('test-product');
+            });
+
+            it('handles multiple spaces', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Test   Product   Name',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('test-product-name');
+            });
+
+            it('converts uppercase to lowercase', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'TEST PRODUCT NAME',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('test-product-name');
+            });
+
+            it('preserves numbers', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Product 123 Version 2',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('product-123-version-2');
+            });
+        });
+
+        describe('special characters and unicode', () => {
+            it('removes special characters', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Product!@#$%^&*()_+Name',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('productname');
+            });
+
+            it('handles special characters with spaces', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Product !@#$ Name',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('product-name');
+            });
+
+            it('handles diacritical marks (accents)', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Café Français naïve résumé',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('cafe-francais-naive-resume');
+            });
+
+            it('handles German umlauts', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Über größer schön',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('uber-groer-schon');
+            });
+
+            it('handles Spanish characters', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Niño Español Añejo',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('nino-espanol-anejo');
+            });
+
+            it('handles non-Latin scripts (removes them)', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Product 商品 المنتج उत्पाद',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('product');
+            });
+
+            it('handles emoji (removes them)', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Cool Product 😎 🚀 Amazing',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('cool-product-amazing');
+            });
+
+            it('handles punctuation and symbols', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Product: The Best! (Version 2.0) - New & Improved',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('product-the-best-version-20-new-improved');
+            });
+        });
+
+        describe('edge cases', () => {
+            it('handles leading and trailing spaces', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: '   Test Product   ',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('test-product');
+            });
+
+            it('handles hyphens correctly', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Test--Product---Name',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('test-product-name');
+            });
+
+            it('handles leading and trailing hyphens', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: '-Test Product-',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('test-product');
+            });
+
+            it('handles empty string', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: '',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('');
+            });
+
+            it('handles only special characters', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: '!@#$%^&*()',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('');
+            });
+
+            it('handles mixed case with numbers and special chars', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: '100% Natural & Organic Product #1',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('100-natural-organic-product-1');
+            });
+        });
+
+        describe('uniqueness handling', () => {
+            it('appends number for duplicate slugs', async () => {
+                // First, create a product with slug 'laptop'
+                const createProduct = await adminClient.query(CREATE_PRODUCT, {
+                    input: {
+                        translations: [
+                            {
+                                languageCode: 'en',
+                                name: 'Laptop',
+                                slug: 'laptop',
+                                description: 'A laptop computer',
+                            },
+                        ],
+                    },
+                });
+
+                // Now try to generate slug for another product with the same base slug
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Laptop',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('laptop-1');
+            });
+
+            it('increments counter for multiple duplicates', async () => {
+                // Create products with slugs 'phone' and 'phone-1'
+                await adminClient.query(CREATE_PRODUCT, {
+                    input: {
+                        translations: [
+                            {
+                                languageCode: 'en',
+                                name: 'Phone',
+                                slug: 'phone',
+                                description: 'A smartphone',
+                            },
+                        ],
+                    },
+                });
+
+                await adminClient.query(CREATE_PRODUCT, {
+                    input: {
+                        translations: [
+                            {
+                                languageCode: 'en',
+                                name: 'Phone 2',
+                                slug: 'phone-1',
+                                description: 'Another smartphone',
+                            },
+                        ],
+                    },
+                });
+
+                // Now generate slug for another phone
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Phone',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('phone-2');
+            });
+
+            it('excludes own ID when checking uniqueness', async () => {
+                // Create a product
+                const createResult = await adminClient.query(CREATE_PRODUCT, {
+                    input: {
+                        translations: [
+                            {
+                                languageCode: 'en',
+                                name: 'Tablet',
+                                slug: 'tablet',
+                                description: 'A tablet device',
+                            },
+                        ],
+                    },
+                });
+
+                const productId = createResult.createProduct.id;
+
+                // Generate slug for the same product (updating scenario)
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Tablet',
+                        entityId: productId,
+                    },
+                });
+
+                // Should return the same slug without appending number
+                expect(result.slugForEntity).toBe('tablet');
+            });
+
+            it('works with different entity types', async () => {
+                // Test with Collection entity (slug field is in CollectionTranslation)
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Collection',
+                        fieldName: 'slug',
+                        inputValue: 'Summer Collection 2024',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('summer-collection-2024');
+            });
+
+            it('handles multi-language slug generation', async () => {
+                // Create a product with English translation first
+                const createProduct = await adminClient.query(CREATE_PRODUCT, {
+                    input: {
+                        translations: [
+                            {
+                                languageCode: LanguageCode.en,
+                                name: 'English Product',
+                                slug: 'english-product',
+                                description: 'Product in English',
+                            },
+                        ],
+                    },
+                });
+
+                const productId = createProduct.createProduct.id;
+
+                // Test generating slug for German translation of the same product
+                const germanResult = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Deutsches Produkt',
+                        entityId: productId,
+                    },
+                });
+
+                expect(germanResult.slugForEntity).toBe('deutsches-produkt');
+
+                // Test generating slug for French translation
+                const frenchResult = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Produit Français',
+                        entityId: productId,
+                    },
+                });
+
+                expect(frenchResult.slugForEntity).toBe('produit-francais');
+            });
+
+            it('handles uniqueness across different language translations', async () => {
+                // Create first product with multiple language translations
+                const product1 = await adminClient.query(CREATE_PRODUCT, {
+                    input: {
+                        translations: [
+                            {
+                                languageCode: LanguageCode.en,
+                                name: 'Computer',
+                                slug: 'computer',
+                                description: 'A computer',
+                            },
+                            {
+                                languageCode: LanguageCode.de,
+                                name: 'Computer',
+                                slug: 'computer-de',
+                                description: 'Ein Computer',
+                            },
+                        ],
+                    },
+                });
+
+                // Generate slug for a new product with same English name
+                const englishSlugResult = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Computer',
+                    },
+                });
+
+                expect(englishSlugResult.slugForEntity).toBe('computer-1');
+
+                // Generate slug with German input that also conflicts
+                const germanSlugResult = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Computer DE',
+                    },
+                });
+
+                expect(germanSlugResult.slugForEntity).toBe('computer-de-1');
+
+                // Generate slug with French input that doesn't conflict
+                const frenchSlugResult = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Ordinateur',
+                    },
+                });
+
+                expect(frenchSlugResult.slugForEntity).toBe('ordinateur');
+            });
+
+            it('handles translation entity exclusion correctly with multiple languages', async () => {
+                // Create a product with multiple language translations
+                const createProduct = await adminClient.query(CREATE_PRODUCT, {
+                    input: {
+                        translations: [
+                            {
+                                languageCode: LanguageCode.en,
+                                name: 'Multilingual Product',
+                                slug: 'multilingual-product',
+                                description: 'Product in English',
+                            },
+                            {
+                                languageCode: LanguageCode.de,
+                                name: 'Mehrsprachiges Produkt',
+                                slug: 'mehrsprachiges-produkt',
+                                description: 'Produkt auf Deutsch',
+                            },
+                        ],
+                    },
+                });
+
+                const productId = createProduct.createProduct.id;
+
+                // Update English translation - should not conflict with itself
+                const englishUpdateResult = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Multilingual Product Updated',
+                        entityId: productId,
+                    },
+                });
+
+                expect(englishUpdateResult.slugForEntity).toBe('multilingual-product-updated');
+
+                // Update German translation - should not conflict with itself
+                const germanUpdateResult = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Mehrsprachiges Produkt Aktualisiert',
+                        entityId: productId,
+                    },
+                });
+
+                expect(germanUpdateResult.slugForEntity).toBe('mehrsprachiges-produkt-aktualisiert');
+            });
+        });
+
+        describe('multi-language collections', () => {
+            it('generates unique slugs for collection translations', async () => {
+                // Create a collection with multiple language translations
+                const createCollection = await adminClient.query(CREATE_COLLECTION, {
+                    input: {
+                        translations: [
+                            {
+                                languageCode: LanguageCode.en,
+                                name: 'Tech Collection',
+                                slug: 'tech-collection',
+                                description: 'Technology products',
+                            },
+                            {
+                                languageCode: LanguageCode.fr,
+                                name: 'Collection Tech',
+                                slug: 'collection-tech',
+                                description: 'Produits technologiques',
+                            },
+                        ],
+                        filters: [],
+                    },
+                });
+
+                const collectionId = createCollection.createCollection.id;
+
+                // Test generating new slug for Spanish translation
+                const spanishResult = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Collection',
+                        fieldName: 'slug',
+                        inputValue: 'Colección Tecnológica',
+                        entityId: collectionId,
+                    },
+                });
+
+                expect(spanishResult.slugForEntity).toBe('coleccion-tecnologica');
+            });
+
+            it('handles collection slug conflicts across languages', async () => {
+                // Create collection with English name
+                await adminClient.query(CREATE_COLLECTION, {
+                    input: {
+                        translations: [
+                            {
+                                languageCode: LanguageCode.en,
+                                name: 'Fashion Collection',
+                                slug: 'fashion-collection',
+                                description: 'Fashion items',
+                            },
+                        ],
+                        filters: [],
+                    },
+                });
+
+                // Generate slug for another collection with similar name
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Collection',
+                        fieldName: 'slug',
+                        inputValue: 'Fashion Collection',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('fashion-collection-1');
+
+                // Test with international name that transliterates to similar slug
+                const internationalResult = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Collection',
+                        fieldName: 'slug',
+                        inputValue: 'Façhion Collêction',
+                    },
+                });
+
+                expect(internationalResult.slugForEntity).toBe('fachion-collection');
+            });
+        });
+
+        describe('international character handling', () => {
+            it('handles various language scripts in slug generation', async () => {
+                // Test different language inputs
+                const testCases = [
+                    { input: 'Café Français', expected: 'cafe-francais' },
+                    { input: 'Niño Español', expected: 'nino-espanol' },
+                    { input: 'Größer Schön', expected: 'groer-schon' },
+                    { input: 'Naïve Résumé', expected: 'naive-resume' },
+                    { input: 'Crème Brûlée', expected: 'creme-brulee' },
+                    { input: 'Piñata Jalapeño', expected: 'pinata-jalapeno' },
+                ];
+
+                for (const testCase of testCases) {
+                    const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                        input: {
+                            entityName: 'Product',
+                            fieldName: 'slug',
+                            inputValue: testCase.input,
+                        },
+                    });
+                    expect(result.slugForEntity).toBe(testCase.expected);
+                }
+            });
+
+            it('handles mixed language input', async () => {
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'English Français Español Deutsch Mix',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('english-francais-espanol-deutsch-mix');
+            });
+        });
+
+        describe('auto-detection functionality', () => {
+            it('auto-detects translation entity for slug field', async () => {
+                // Using base entity name, should automatically detect ProductTranslation
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Product',
+                        fieldName: 'slug',
+                        inputValue: 'Auto Detection Test',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('auto-detection-test');
+            });
+
+            it('works with explicit translation entity names', async () => {
+                // Still works when explicitly using translation entity name
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'ProductTranslation',
+                        fieldName: 'slug',
+                        inputValue: 'Explicit Translation Test',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('explicit-translation-test');
+            });
+
+            it('works with Collection entity auto-detection', async () => {
+                // Using base entity name, should automatically detect CollectionTranslation
+                const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                    input: {
+                        entityName: 'Collection',
+                        fieldName: 'slug',
+                        inputValue: 'Collection Auto Detection',
+                    },
+                });
+
+                expect(result.slugForEntity).toBe('collection-auto-detection');
+            });
+
+            it('auto-detects translation entities for different languages', async () => {
+                // Test that auto-detection works regardless of the intended language
+                const testCases = [
+                    { input: 'Auto Detection English', expected: 'auto-detection-english' },
+                    { input: 'Détection Automatique', expected: 'detection-automatique' },
+                    { input: 'Detección Automática', expected: 'deteccion-automatica' },
+                    { input: 'Automatische Erkennung', expected: 'automatische-erkennung' },
+                ];
+
+                for (const testCase of testCases) {
+                    const result = await adminClient.query(SLUG_FOR_ENTITY, {
+                        input: {
+                            entityName: 'Product',
+                            fieldName: 'slug',
+                            inputValue: testCase.input,
+                        },
+                    });
+                    expect(result.slugForEntity).toBe(testCase.expected);
+                }
+            });
+        });
+
+        describe('error handling', () => {
+            it('throws error for non-existent entity', async () => {
+                try {
+                    await adminClient.query(SLUG_FOR_ENTITY, {
+                        input: {
+                            entityName: 'NonExistentEntity',
+                            fieldName: 'slug',
+                            inputValue: 'Test',
+                        },
+                    });
+                    expect.fail('Should have thrown an error');
+                } catch (error: any) {
+                    expect(error.message).toContain('error.entity-not-found');
+                }
+            });
+
+            it('throws error for non-existent field', async () => {
+                try {
+                    await adminClient.query(SLUG_FOR_ENTITY, {
+                        input: {
+                            entityName: 'Product',
+                            fieldName: 'nonExistentField',
+                            inputValue: 'Test',
+                        },
+                    });
+                    expect.fail('Should have thrown an error');
+                } catch (error: any) {
+                    expect(error.message).toContain('error.entity-has-no-field');
+                }
+            });
+        });
+    });
+});
+
+const SLUG_FOR_ENTITY = gql`
+    query SlugForEntity($input: SlugForEntityInput!) {
+        slugForEntity(input: $input)
+    }
+`;

+ 2 - 0
packages/core/src/api/api-internal-modules.ts

@@ -37,6 +37,7 @@ import { SearchResolver } from './resolvers/admin/search.resolver';
 import { SellerResolver } from './resolvers/admin/seller.resolver';
 import { SettingsStoreAdminResolver } from './resolvers/admin/settings-store.resolver';
 import { ShippingMethodResolver } from './resolvers/admin/shipping-method.resolver';
+import { SlugResolver } from './resolvers/admin/slug.resolver';
 import { StockLocationResolver } from './resolvers/admin/stock-location.resolver';
 import { TagResolver } from './resolvers/admin/tag.resolver';
 import { TaxCategoryResolver } from './resolvers/admin/tax-category.resolver';
@@ -118,6 +119,7 @@ const adminResolvers = [
     SearchResolver,
     ScheduledTaskResolver,
     ShippingMethodResolver,
+    SlugResolver,
     StockLocationResolver,
     TagResolver,
     TaxCategoryResolver,

+ 3 - 2
packages/core/src/api/config/graphql-custom-fields.ts

@@ -10,6 +10,7 @@ import {
 } from 'graphql';
 
 import {
+    BaseTypedCustomFieldConfig,
     CustomFieldConfig,
     CustomFields,
     StructCustomFieldConfig,
@@ -267,7 +268,7 @@ export function addGraphQLCustomFields(
                     type ${publicEntityName}CustomFields {
                         ${mapToFields(customEntityFields, wrapListType(getGraphQlType(entityName)))}
                     }
-    
+
                     extend type ${publicEntityName} {
                         customFields: ${publicEntityName}CustomFields
                     }
@@ -751,7 +752,7 @@ function pascalCase(input: string) {
     return input.charAt(0).toUpperCase() + input.slice(1);
 }
 
-function getDeprecationDirective(field: CustomFieldConfig): string {
+function getDeprecationDirective(field: BaseTypedCustomFieldConfig<any, any>): string {
     if (!field.deprecated) {
         return '';
     }

+ 18 - 0
packages/core/src/api/resolvers/admin/slug.resolver.ts

@@ -0,0 +1,18 @@
+import { Args, Query, Resolver } from '@nestjs/graphql';
+import { Permission, QuerySlugForEntityArgs } from '@vendure/common/lib/generated-types';
+
+import { EntitySlugService } from '../../../service/helpers/entity-slug.service';
+import { RequestContext } from '../../common/request-context';
+import { Allow } from '../../decorators/allow.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver()
+export class SlugResolver {
+    constructor(private entitySlugService: EntitySlugService) {}
+
+    @Query()
+    @Allow(Permission.Authenticated)
+    async slugForEntity(@Ctx() ctx: RequestContext, @Args() args: QuerySlugForEntityArgs): Promise<string> {
+        return this.entitySlugService.generateSlugFromInput(ctx, args.input);
+    }
+}

+ 11 - 0
packages/core/src/api/schema/admin-api/slug.api.graphql

@@ -0,0 +1,11 @@
+type Query {
+    "Generate slug for entity"
+    slugForEntity(input: SlugForEntityInput!): String!
+}
+
+input SlugForEntityInput {
+    entityName: String!
+    fieldName: String!
+    inputValue: String!
+    entityId: ID
+}

+ 2 - 0
packages/core/src/config/default-config.ts

@@ -29,6 +29,7 @@ import { DefaultStockDisplayStrategy } from './catalog/default-stock-display-str
 import { MultiChannelStockLocationStrategy } from './catalog/multi-channel-stock-location-strategy';
 import { AutoIncrementIdStrategy } from './entity/auto-increment-id-strategy';
 import { DefaultMoneyStrategy } from './entity/default-money-strategy';
+import { DefaultSlugStrategy } from './entity/default-slug-strategy';
 import { defaultEntityDuplicators } from './entity/entity-duplicators/index';
 import { defaultFulfillmentProcess } from './fulfillment/default-fulfillment-process';
 import { manualFulfillmentHandler } from './fulfillment/manual-fulfillment-handler';
@@ -147,6 +148,7 @@ export const defaultConfig: RuntimeVendureConfig = {
         zoneCacheTtl: 30000,
         taxRateCacheTtl: 30000,
         metadataModifiers: [],
+        slugStrategy: new DefaultSlugStrategy(),
     },
     promotionOptions: {
         promotionConditions: defaultPromotionConditions,

+ 46 - 0
packages/core/src/config/entity/default-slug-strategy.ts

@@ -0,0 +1,46 @@
+import { RequestContext } from '../../api/common/request-context';
+
+import { SlugGenerateParams, SlugStrategy } from './slug-strategy';
+
+/**
+ * @description
+ * The default strategy for generating slugs. This strategy:
+ * - Converts to lowercase
+ * - Replaces spaces and special characters with hyphens
+ * - Removes non-alphanumeric characters (except hyphens)
+ * - Removes leading and trailing hyphens
+ * - Collapses multiple hyphens into one
+ *
+ * @example
+ * ```ts
+ * const strategy = new DefaultSlugStrategy();
+ * strategy.generate(ctx, { value: "Hello World!" }); // "hello-world"
+ * strategy.generate(ctx, { value: "Café Français" }); // "cafe-francais"
+ * strategy.generate(ctx, { value: "100% Natural" }); // "100-natural"
+ * ```
+ *
+ * @docsCategory configuration
+ * @since 3.5.0
+ */
+export class DefaultSlugStrategy implements SlugStrategy {
+    generate(ctx: RequestContext, params: SlugGenerateParams): string {
+        const { value } = params;
+        if (!value) {
+            return '';
+        }
+
+        const result = value
+            .normalize('NFD') // Normalize unicode characters
+            .replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks
+            .toLowerCase()
+            .trim()
+            .replace(/[^a-z0-9\s-]/g, '') // Remove special characters except spaces and hyphens
+            .replace(/\s+/g, '-'); // Replace spaces with hyphens
+
+        // Split by hyphen, filter out empty strings, and rejoin to handle multiple hyphens
+        return result
+            .split('-')
+            .filter(part => part.length > 0)
+            .join('-');
+    }
+}

+ 54 - 0
packages/core/src/config/entity/slug-strategy.ts

@@ -0,0 +1,54 @@
+import { RequestContext } from '../../api/common/request-context';
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+
+/**
+ * @description
+ * Parameters for slug generation
+ */
+export interface SlugGenerateParams {
+    /**
+     * The input string to be converted to a slug
+     */
+    value: string;
+    /**
+     * The optional entity name (e.g., 'Product', 'Collection')
+     */
+    entityName?: string;
+    /**
+     * The optional field name (e.g., 'slug', 'code')
+     */
+    fieldName?: string;
+}
+
+/**
+ * @description
+ * Defines the strategy for generating slugs from input strings.
+ * Slugs are URL-friendly versions of text that are commonly used for
+ * entity identifiers in URLs.
+ *
+ * @example
+ * ```ts
+ * export class CustomSlugStrategy implements SlugStrategy {
+ *   generate(ctx: RequestContext, params: SlugGenerateParams): string {
+ *     return params.value
+ *       .toLowerCase()
+ *       .replace(/[^a-z0-9]+/g, '-')
+ *       .replace(/^-+|-+$/g, '');
+ *   }
+ * }
+ * ```
+ *
+ * @docsCategory configuration
+ * @since 3.5.0
+ */
+export interface SlugStrategy extends InjectableStrategy {
+    /**
+     * @description
+     * Generates a slug from the input string.
+     *
+     * @param ctx The request context
+     * @param params The parameters for slug generation
+     * @returns A URL-friendly slug string
+     */
+    generate(ctx: RequestContext, params: SlugGenerateParams): string | Promise<string>;
+}

+ 2 - 0
packages/core/src/config/index.ts

@@ -32,10 +32,12 @@ export * from './entity-metadata/entity-metadata-modifier';
 export * from './entity/auto-increment-id-strategy';
 export * from './entity/bigint-money-strategy';
 export * from './entity/default-money-strategy';
+export * from './entity/default-slug-strategy';
 export * from './entity/entity-duplicator';
 export * from './entity/entity-duplicators/index';
 export * from './entity/entity-id-strategy';
 export * from './entity/money-strategy';
+export * from './entity/slug-strategy';
 export * from './entity/uuid-id-strategy';
 export * from './fulfillment/default-fulfillment-process';
 export * from './fulfillment/fulfillment-handler';

+ 10 - 0
packages/core/src/config/vendure-config.ts

@@ -31,6 +31,7 @@ import { EntityMetadataModifier } from './entity-metadata/entity-metadata-modifi
 import { EntityDuplicator } from './entity/entity-duplicator';
 import { EntityIdStrategy } from './entity/entity-id-strategy';
 import { MoneyStrategy } from './entity/money-strategy';
+import { SlugStrategy } from './entity/slug-strategy';
 import { FulfillmentHandler } from './fulfillment/fulfillment-handler';
 import { FulfillmentProcess } from './fulfillment/fulfillment-process';
 import { JobQueueStrategy } from './job-queue/job-queue-strategy';
@@ -1112,6 +1113,15 @@ export interface EntityOptions {
      * @default []
      */
     metadataModifiers?: EntityMetadataModifier[];
+    /**
+     * @description
+     * Defines the strategy for generating slugs from input strings.
+     * Slugs are URL-friendly versions of text commonly used for entity identifiers in URLs.
+     *
+     * @since 3.5.0
+     * @default DefaultSlugStrategy
+     */
+    slugStrategy?: SlugStrategy;
 }
 
 /**

+ 197 - 0
packages/core/src/service/helpers/entity-slug.service.ts

@@ -0,0 +1,197 @@
+import { Injectable } from '@nestjs/common';
+import { EntityMetadata, ObjectLiteral, Repository } from 'typeorm';
+
+import { RequestContext } from '../../api/common/request-context';
+import { UserInputError } from '../../common/error/errors';
+import { TransactionalConnection } from '../../connection/transactional-connection';
+
+import { SlugService } from './slug.service';
+
+/**
+ * @description
+ * Parameters for entity slug generation
+ */
+export interface GenerateSlugFromInputParams {
+    /**
+     * The name of the entity (base entity, e.g., 'Product', 'Collection')
+     */
+    entityName: string;
+    /**
+     * The name of the field to check for uniqueness (e.g., 'slug', 'code')
+     */
+    fieldName: string;
+    /**
+     * The value to generate the slug from
+     */
+    inputValue: string;
+    /**
+     * Optional ID of the entity being updated
+     */
+    entityId?: string | number;
+}
+
+/**
+ * @description
+ * A service that handles slug generation for entities, ensuring uniqueness
+ * and handling conflicts by appending numbers.
+ *
+ * @docsCategory services
+ * @since 3.5.0
+ */
+@Injectable()
+export class EntitySlugService {
+    constructor(
+        private slugService: SlugService,
+        private connection: TransactionalConnection,
+    ) {}
+
+    /**
+     * @description
+     * Generates a slug from input value for an entity, ensuring uniqueness.
+     * Automatically detects if the field exists on the base entity or its translation entity.
+     *
+     * @param ctx The request context
+     * @param params Parameters for slug generation
+     * @returns A unique slug string
+     */
+    async generateSlugFromInput(ctx: RequestContext, params: GenerateSlugFromInputParams): Promise<string> {
+        const { entityName, fieldName, inputValue, entityId } = params;
+
+        // Short-circuit for empty inputValue
+        const baseSlug = await this.slugService.generate(ctx, {
+            value: inputValue,
+            entityName,
+            fieldName,
+        });
+
+        if (!baseSlug) {
+            return baseSlug;
+        }
+
+        const { entityMetadata, resolvedColumnName, isTranslationEntity, ownerRelationColumnName } =
+            this.findEntityWithField(entityName, fieldName);
+
+        const repository = this.connection.getRepository(ctx, entityMetadata.target);
+        let slug = baseSlug;
+        let counter = 1;
+
+        const exclusionConfig =
+            isTranslationEntity && entityId && ownerRelationColumnName
+                ? { columnName: ownerRelationColumnName, value: entityId }
+                : entityId
+                  ? { columnName: 'id', value: entityId }
+                  : undefined;
+
+        while (await this.fieldValueExists(ctx, repository, resolvedColumnName, slug, exclusionConfig)) {
+            slug = `${baseSlug}-${counter}`;
+            counter++;
+        }
+
+        return slug;
+    }
+
+    /**
+     * @description
+     * Finds the entity metadata for the given entity name and field name.
+     * If the field doesn't exist on the base entity, it checks the translation entity.
+     *
+     * @param entityName The base entity name
+     * @param fieldName The field name to find
+     * @returns Object containing entityMetadata, actualEntityName, resolvedColumnName, and isTranslationEntity
+     */
+    private findEntityWithField(
+        entityName: string,
+        fieldName: string,
+    ): {
+        entityMetadata: EntityMetadata;
+        actualEntityName: string;
+        resolvedColumnName: string;
+        isTranslationEntity: boolean;
+        ownerRelationColumnName?: string;
+    } {
+        // First, try to find the base entity
+        const entityMetadata = this.connection.rawConnection.entityMetadatas.find(
+            metadata => metadata.name === entityName,
+        );
+
+        if (!entityMetadata) {
+            throw new UserInputError(`error.entity-not-found`, {
+                entityName,
+            });
+        }
+
+        // Check if the field exists on the base entity
+        const baseEntityColumn = entityMetadata.columns.find(col => col.propertyName === fieldName);
+
+        if (baseEntityColumn) {
+            return {
+                entityMetadata,
+                actualEntityName: entityName,
+                resolvedColumnName: baseEntityColumn.databaseName,
+                isTranslationEntity: false,
+            };
+        }
+
+        // If field doesn't exist on base entity, try to find the translation entity through relations
+        const translationRelation = entityMetadata.relations.find(r => r.propertyName === 'translations');
+
+        if (!translationRelation) {
+            throw new UserInputError(`error.entity-has-no-field`, {
+                entityName,
+                fieldName,
+            });
+        }
+
+        // Get the translation entity metadata from the relation
+        const translationMetadata = this.connection.rawConnection.getMetadata(translationRelation.type);
+
+        if (!translationMetadata) {
+            throw new UserInputError(`error.entity-has-no-field`, {
+                entityName,
+                fieldName,
+            });
+        }
+
+        const translationColumn = translationMetadata.columns.find(col => col.propertyName === fieldName);
+
+        if (translationColumn) {
+            // Find the owner relation column (e.g., 'baseId', 'productId', etc.)
+            const ownerRelation = translationMetadata.relations.find(r => r.type === entityMetadata.target);
+            const ownerColumnName = ownerRelation?.joinColumns?.[0]?.databaseName || 'baseId';
+
+            return {
+                entityMetadata: translationMetadata,
+                actualEntityName: translationMetadata.name,
+                resolvedColumnName: translationColumn.databaseName,
+                isTranslationEntity: true,
+                ownerRelationColumnName: ownerColumnName,
+            };
+        }
+
+        throw new UserInputError(`error.entity-has-no-field`, {
+            entityName,
+            fieldName,
+        });
+    }
+
+    private async fieldValueExists(
+        _ctx: RequestContext,
+        repository: Repository<ObjectLiteral>,
+        resolvedColumnName: string,
+        value: string,
+        exclusionConfig?: { columnName: string; value: string | number },
+    ): Promise<boolean> {
+        const qb = repository
+            .createQueryBuilder('entity')
+            .where(`entity.${resolvedColumnName} = :value`, { value });
+
+        if (exclusionConfig) {
+            qb.andWhere(`entity.${exclusionConfig.columnName} != :excludeValue`, {
+                excludeValue: exclusionConfig.value,
+            });
+        }
+
+        const count = await qb.getCount();
+        return count > 0;
+    }
+}

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

@@ -0,0 +1,32 @@
+import { Injectable } from '@nestjs/common';
+
+import { RequestContext } from '../../api/common/request-context';
+import { ConfigService, SlugGenerateParams } from '../../config';
+
+/**
+ * @description
+ * A service that handles slug generation using the configured SlugStrategy.
+ *
+ * @docsCategory services
+ * @since 3.5.0
+ */
+@Injectable()
+export class SlugService {
+    constructor(private configService: ConfigService) {}
+
+    /**
+     * @description
+     * Generates a slug from the input string using the configured SlugStrategy.
+     *
+     * @param ctx The request context
+     * @param params The parameters for slug generation
+     * @returns A URL-friendly slug string
+     */
+    async generate(ctx: RequestContext, params: SlugGenerateParams): Promise<string> {
+        const strategy = this.configService.entityOptions.slugStrategy;
+        if (!strategy) {
+            throw new Error('No SlugStrategy configured');
+        }
+        return strategy.generate(ctx, params);
+    }
+}

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

@@ -4,6 +4,7 @@ export * from './helpers/custom-field-relation/custom-field-relation.service';
 export * from './helpers/entity-duplicator/entity-duplicator.service';
 export * from './helpers/entity-hydrator/entity-hydrator.service';
 export * from './helpers/entity-hydrator/merge-deep';
+export * from './helpers/entity-slug.service';
 export * from './helpers/external-authentication/external-authentication.service';
 export * from './helpers/facet-value-checker/facet-value-checker';
 export * from './helpers/fulfillment-state-machine/fulfillment-state';
@@ -22,6 +23,7 @@ export * from './helpers/product-price-applicator/product-price-applicator';
 export * from './helpers/refund-state-machine/refund-state';
 export * from './helpers/request-context/request-context.service';
 export * from './helpers/settings-store/settings-store.service';
+export * from './helpers/slug.service';
 export * from './helpers/translatable-saver/translatable-saver';
 export * from './helpers/translator/translator.service';
 export * from './helpers/utils/order-utils';

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

@@ -12,6 +12,7 @@ import { ConfigArgService } from './helpers/config-arg/config-arg.service';
 import { CustomFieldRelationService } from './helpers/custom-field-relation/custom-field-relation.service';
 import { EntityDuplicatorService } from './helpers/entity-duplicator/entity-duplicator.service';
 import { EntityHydrator } from './helpers/entity-hydrator/entity-hydrator.service';
+import { EntitySlugService } from './helpers/entity-slug.service';
 import { ExternalAuthenticationService } from './helpers/external-authentication/external-authentication.service';
 import { FacetValueChecker } from './helpers/facet-value-checker/facet-value-checker';
 import { FulfillmentStateMachine } from './helpers/fulfillment-state-machine/fulfillment-state-machine';
@@ -30,6 +31,7 @@ import { RequestContextService } from './helpers/request-context/request-context
 import { SettingsStoreService } from './helpers/settings-store/settings-store.service';
 import { ShippingCalculator } from './helpers/shipping-calculator/shipping-calculator';
 import { SlugValidator } from './helpers/slug-validator/slug-validator';
+import { SlugService } from './helpers/slug.service';
 import { TranslatableSaver } from './helpers/translatable-saver/translatable-saver';
 import { TranslatorService } from './helpers/translator/translator.service';
 import { VerificationTokenGenerator } from './helpers/verification-token-generator/verification-token-generator';
@@ -79,6 +81,7 @@ const services = [
     CountryService,
     CustomerGroupService,
     CustomerService,
+    EntitySlugService,
     FacetService,
     FacetValueService,
     FulfillmentService,
@@ -98,6 +101,7 @@ const services = [
     SellerService,
     SessionService,
     ShippingMethodService,
+    SlugService,
     StockLevelService,
     StockLocationService,
     StockMovementService,

+ 15 - 2
packages/dashboard/src/app/routes/_authenticated/_collections/collections_.$id.tsx

@@ -1,3 +1,4 @@
+import { SlugInput } from '@/vdb/components/data-input/index.js';
 import { RichTextInput } from '@/vdb/components/data-input/rich-text-input.js';
 import { EntityAssets } from '@/vdb/components/shared/entity-assets.js';
 import { ErrorPage } from '@/vdb/components/shared/error-page.js';
@@ -89,7 +90,11 @@ function CollectionDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast(i18n.t(creatingNewEntity ? 'Successfully created collection' : 'Successfully updated collection'));
+            toast(
+                i18n.t(
+                    creatingNewEntity ? 'Successfully created collection' : 'Successfully updated collection',
+                ),
+            );
             resetForm();
             if (creatingNewEntity) {
                 await navigate({ to: `../$id`, params: { id: data.id } });
@@ -147,7 +152,15 @@ function CollectionDetailPage() {
                             control={form.control}
                             name="slug"
                             label={<Trans>Slug</Trans>}
-                            render={({ field }) => <Input {...field} />}
+                            render={({ field }) => (
+                                <SlugInput
+                                    fieldName="slug"
+                                    watchFieldName="name"
+                                    entityName="Collection"
+                                    entityId={entity?.id}
+                                    {...field}
+                                />
+                            )}
                         />
                     </DetailFormGrid>
                     <TranslatableFormFieldWrapper

+ 14 - 2
packages/dashboard/src/app/routes/_authenticated/_facets/facets_.$id.tsx

@@ -1,3 +1,4 @@
+import { SlugInput } from '@/vdb/components/data-input/index.js';
 import { ErrorPage } from '@/vdb/components/shared/error-page.js';
 import { FormFieldWrapper } from '@/vdb/components/shared/form-field-wrapper.js';
 import { PermissionGuard } from '@/vdb/components/shared/permission-guard.js';
@@ -32,7 +33,10 @@ export const Route = createFileRoute('/_authenticated/_facets/facets_/$id')({
         pageId,
         queryDocument: facetDetailDocument,
         breadcrumb(isNew, entity) {
-            return [{ path: '/facets', label: <Trans>Facets</Trans> }, isNew ? <Trans>New facet</Trans> : entity?.name];
+            return [
+                { path: '/facets', label: <Trans>Facets</Trans> },
+                isNew ? <Trans>New facet</Trans> : entity?.name,
+            ];
         },
     }),
     errorComponent: ({ error }) => <ErrorPage message={error.message} />,
@@ -124,7 +128,15 @@ function FacetDetailPage() {
                             control={form.control}
                             name="code"
                             label={<Trans>Code</Trans>}
-                            render={({ field }) => <Input {...field} />}
+                            render={({ field }) => (
+                                <SlugInput
+                                    fieldName="code"
+                                    watchFieldName="name"
+                                    entityName="Facet"
+                                    entityId={entity?.id}
+                                    {...field}
+                                />
+                            )}
                         />
                     </DetailFormGrid>
                 </PageBlock>

+ 13 - 2
packages/dashboard/src/app/routes/_authenticated/_products/products_.$id.tsx

@@ -1,4 +1,5 @@
 import { RichTextInput } from '@/vdb/components/data-input/rich-text-input.js';
+import { SlugInput } from '@/vdb/components/data-input/slug-input.js';
 import { AssignedFacetValues } from '@/vdb/components/shared/assigned-facet-values.js';
 import { EntityAssets } from '@/vdb/components/shared/entity-assets.js';
 import { ErrorPage } from '@/vdb/components/shared/error-page.js';
@@ -81,7 +82,9 @@ function ProductDetailPage() {
         },
         params: { id: params.id },
         onSuccess: async data => {
-            toast.success(i18n.t(creatingNewEntity ? 'Successfully created product' : 'Successfully updated product'));
+            toast.success(
+                i18n.t(creatingNewEntity ? 'Successfully created product' : 'Successfully updated product'),
+            );
             resetForm();
             if (creatingNewEntity) {
                 await navigate({ to: `../$id`, params: { id: data.id } });
@@ -133,7 +136,15 @@ function ProductDetailPage() {
                             control={form.control}
                             name="slug"
                             label={<Trans>Slug</Trans>}
-                            render={({ field }) => <Input {...field} />}
+                            render={({ field }) => (
+                                <SlugInput
+                                    {...field}
+                                    entityName="Product"
+                                    fieldName="slug"
+                                    watchFieldName="name"
+                                    entityId={entity?.id}
+                                />
+                            )}
                         />
                     </DetailFormGrid>
 

+ 3 - 0
packages/dashboard/src/lib/components/data-input/index.ts

@@ -15,3 +15,6 @@ export * from './product-multi-selector-input.js';
 // Relation selector components
 export * from './relation-input.js';
 export * from './relation-selector.js';
+
+// Slug input component
+export * from './slug-input.js';

+ 296 - 0
packages/dashboard/src/lib/components/data-input/slug-input.tsx

@@ -0,0 +1,296 @@
+import { Button } from '@/vdb/components/ui/button.js';
+import { Input } from '@/vdb/components/ui/input.js';
+import { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
+import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
+import { api } from '@/vdb/graphql/api.js';
+import { graphql } from '@/vdb/graphql/graphql.js';
+import { useUserSettings } from '@/vdb/hooks/use-user-settings.js';
+import { useLingui } from '@/vdb/lib/trans.js';
+import { cn } from '@/vdb/lib/utils.js';
+import { useQuery } from '@tanstack/react-query';
+import { useDebounce } from '@uidotdev/usehooks';
+import { Edit, Lock, RefreshCw } from 'lucide-react';
+import { useEffect, useState } from 'react';
+import { useFormContext, useWatch } from 'react-hook-form';
+
+const slugForEntityDocument = graphql(`
+    query SlugForEntity($input: SlugForEntityInput!) {
+        slugForEntity(input: $input)
+    }
+`);
+
+function resolveWatchFieldPath(
+    currentFieldName: string,
+    watchFieldName: string,
+    formValues: any,
+    contentLanguage: string,
+): string {
+    const translationsMatch = currentFieldName.match(/^translations\.(\d+)\./);
+
+    if (translationsMatch) {
+        const index = translationsMatch[1];
+
+        if (formValues?.translations?.[index]?.hasOwnProperty(watchFieldName)) {
+            return `translations.${index}.${watchFieldName}`;
+        }
+
+        if (formValues?.hasOwnProperty(watchFieldName)) {
+            return watchFieldName;
+        }
+
+        return `translations.${index}.${watchFieldName}`;
+    }
+
+    if (formValues?.translations && Array.isArray(formValues.translations)) {
+        const translations = formValues.translations;
+        const existingIndex = translations.findIndex(
+            (translation: any) => translation?.languageCode === contentLanguage,
+        );
+        const index = existingIndex === -1 ? (translations.length > 0 ? 0 : -1) : existingIndex;
+
+        if (index >= 0 && translations[index]?.hasOwnProperty(watchFieldName)) {
+            return `translations.${index}.${watchFieldName}`;
+        }
+    }
+
+    return watchFieldName;
+}
+
+export interface SlugInputProps extends DashboardFormComponentProps {
+    /**
+     * @description
+     * The name of the entity (e.g., 'Product', 'Collection')
+     */
+    entityName: string;
+    /**
+     * @description
+     * The name of the field to check for uniqueness (e.g., 'slug', 'code')
+     */
+    fieldName: string;
+    /**
+     * @description
+     * The name of the field to watch for changes (e.g., 'name', 'title', 'enabled').
+     * The component automatically resolves whether this field exists in translations
+     * or on the base entity. For translatable fields like 'name', it will watch
+     * 'translations.X.name'. For non-translatable fields like 'enabled', it will
+     * watch 'enabled' directly.
+     */
+    watchFieldName: string;
+    /**
+     * @description
+     * Optional entity ID for updates (excludes from uniqueness check)
+     */
+    entityId?: string | number;
+    /**
+     * @description
+     * Whether the input should start in readonly mode. Default: true
+     */
+    defaultReadonly?: boolean;
+
+    /**
+     * @description Class name for the <Input> component
+     */
+    className?: string;
+}
+
+/**
+ * @description
+ * A component for generating and displaying slugs based on a watched field.
+ * The component watches a source field for changes, debounces the input,
+ * and generates a unique slug via the Admin API. The slug is only auto-generated
+ * when it's empty. For existing slugs, a regenerate button allows manual regeneration.
+ * The input is readonly by default but can be made editable with a toggle button.
+ *
+ * @example
+ * ```tsx
+ * // In a TranslatableFormFieldWrapper context with translatable field
+ * <SlugInput
+ *     {...field}
+ *     entityName="Product"
+ *     fieldName="slug"
+ *     watchFieldName="name" // Automatically resolves to "translations.X.name"
+ *     entityId={productId}
+ * />
+ *
+ * // In a TranslatableFormFieldWrapper context with non-translatable field
+ * <SlugInput
+ *     {...field}
+ *     entityName="Product"
+ *     fieldName="slug"
+ *     watchFieldName="enabled" // Uses "enabled" directly (base entity field)
+ *     entityId={productId}
+ * />
+ *
+ * // For non-translatable entities
+ * <SlugInput
+ *     {...field}
+ *     entityName="Channel"
+ *     fieldName="code"
+ *     watchFieldName="name" // Uses "name" directly
+ *     entityId={channelId}
+ * />
+ * ```
+ *
+ * @docsCategory form-components
+ * @docsPage SlugInput
+ */
+export function SlugInput({
+    value,
+    onChange,
+    fieldDef,
+    entityName,
+    fieldName,
+    watchFieldName,
+    entityId,
+    defaultReadonly = true,
+    className,
+    name,
+    ...props
+}: SlugInputProps) {
+    const { i18n } = useLingui();
+    const form = useFormContext();
+    const { contentLanguage } = useUserSettings().settings;
+    const isFormReadonly = isReadonlyField(fieldDef);
+    const [isManuallyReadonly, setIsManuallyReadonly] = useState(defaultReadonly);
+    const isReadonly = isFormReadonly || isManuallyReadonly;
+
+    const actualWatchFieldName = resolveWatchFieldPath(
+        name || '',
+        watchFieldName,
+        form?.getValues(),
+        contentLanguage,
+    );
+
+    const watchedValue = useWatch({
+        control: form?.control,
+        name: actualWatchFieldName,
+    });
+
+    const watchFieldState = form.getFieldState(actualWatchFieldName);
+    const debouncedWatchedValue = useDebounce(watchedValue, 500);
+
+    const shouldAutoGenerate = isReadonly && !value && watchFieldState.isDirty;
+
+    const {
+        data: generatedSlug,
+        isLoading,
+        refetch,
+    } = useQuery({
+        queryKey: ['slugForEntity', entityName, fieldName, debouncedWatchedValue, entityId],
+        queryFn: async () => {
+            if (!debouncedWatchedValue) {
+                return '';
+            }
+
+            const result = await api.query(slugForEntityDocument, {
+                input: {
+                    entityName,
+                    fieldName,
+                    inputValue: debouncedWatchedValue,
+                    entityId: entityId?.toString(),
+                },
+            });
+
+            return result.slugForEntity;
+        },
+        enabled: !!debouncedWatchedValue && shouldAutoGenerate,
+    });
+
+    useEffect(() => {
+        if (isReadonly && generatedSlug && generatedSlug !== value) {
+            onChange?.(generatedSlug);
+        }
+    }, [generatedSlug, isReadonly, value, onChange]);
+
+    const toggleReadonly = () => {
+        if (!isFormReadonly) {
+            setIsManuallyReadonly(!isManuallyReadonly);
+        }
+    };
+
+    const handleRegenerate = async () => {
+        if (watchedValue) {
+            const result = await refetch();
+            if (result.data) {
+                onChange?.(result.data);
+            }
+        }
+    };
+
+    const handleChange = (newValue: string) => {
+        onChange?.(newValue);
+    };
+
+    const displayValue = isReadonly && generatedSlug ? generatedSlug : value || '';
+    const showLoading = isLoading && isReadonly;
+
+    return (
+        <div className="relative flex items-center gap-2">
+            <div className="flex-1 relative">
+                <Input
+                    value={displayValue}
+                    onChange={e => handleChange(e.target.value)}
+                    disabled={isReadonly}
+                    placeholder={
+                        isReadonly
+                            ? value
+                                ? i18n.t('Slug is set')
+                                : i18n.t('Slug will be generated automatically...')
+                            : i18n.t('Enter slug manually')
+                    }
+                    className={cn(
+                        'pr-8',
+                        isReadonly && 'bg-muted text-muted-foreground',
+                        showLoading && 'text-muted-foreground',
+                        className,
+                    )}
+                    {...props}
+                />
+                {showLoading && (
+                    <div className="absolute right-3 top-1/2 -translate-y-1/2">
+                        <div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
+                    </div>
+                )}
+            </div>
+
+            {!isFormReadonly && (
+                <>
+                    {isManuallyReadonly && value && (
+                        <Button
+                            type="button"
+                            variant="outline"
+                            size="sm"
+                            onClick={handleRegenerate}
+                            className="shrink-0"
+                            title={i18n.t('Regenerate slug from source field')}
+                            aria-label={i18n.t('Regenerate slug from source field')}
+                            disabled={!watchedValue || isLoading}
+                        >
+                            <RefreshCw className="h-4 w-4" />
+                        </Button>
+                    )}
+
+                    <Button
+                        type="button"
+                        variant="outline"
+                        size="sm"
+                        onClick={toggleReadonly}
+                        className="shrink-0"
+                        title={
+                            isManuallyReadonly
+                                ? i18n.t('Edit slug manually')
+                                : i18n.t('Generate slug automatically')
+                        }
+                        aria-label={
+                            isManuallyReadonly
+                                ? i18n.t('Edit slug manually')
+                                : i18n.t('Generate slug automatically')
+                        }
+                    >
+                        {isManuallyReadonly ? <Edit className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
+                    </Button>
+                </>
+            )}
+        </div>
+    );
+}

Різницю між файлами не показано, бо вона завелика
+ 0 - 0
packages/dashboard/src/lib/graphql/graphql-env.d.ts


Різницю між файлами не показано, бо вона завелика
+ 0 - 0
packages/dev-server/graphql/graphql-env.d.ts


Деякі файли не було показано, через те що забагато файлів було змінено