Browse Source

feat(dashboard): Custom form components for custom fields (#3610)

David Höck 6 months ago
parent
commit
155f376997
24 changed files with 666 additions and 153 deletions
  1. 157 45
      packages/dashboard/src/lib/components/shared/custom-fields-form.tsx
  2. 18 13
      packages/dashboard/src/lib/components/shared/customer-address-form.tsx
  3. 2 2
      packages/dashboard/src/lib/components/shared/logo-mark.tsx
  4. 2 8
      packages/dashboard/src/lib/components/shared/translatable-form-field.tsx
  5. 43 10
      packages/dashboard/src/lib/framework/document-introspection/get-document-structure.ts
  6. 6 0
      packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts
  7. 19 0
      packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts
  8. 28 0
      packages/dashboard/src/lib/framework/form-engine/custom-form-component-extensions.ts
  9. 33 0
      packages/dashboard/src/lib/framework/form-engine/custom-form-component.tsx
  10. 50 11
      packages/dashboard/src/lib/framework/form-engine/use-generated-form.tsx
  11. 52 29
      packages/dashboard/src/lib/framework/page/detail-page.tsx
  12. 18 2
      packages/dashboard/src/lib/framework/page/use-detail-page.ts
  13. 2 0
      packages/dashboard/src/lib/framework/registry/registry-types.ts
  14. 0 0
      packages/dashboard/src/lib/graphql/graphql-env.d.ts
  15. 29 1
      packages/dashboard/src/lib/index.ts
  16. 49 0
      packages/dashboard/src/lib/lib/utils.ts
  17. 0 0
      packages/dev-server/graphql/graphql-env.d.ts
  18. 13 0
      packages/dev-server/test-plugins/reviews/api/api-extensions.ts
  19. 23 8
      packages/dev-server/test-plugins/reviews/api/product-review-admin.resolver.ts
  20. 5 0
      packages/dev-server/test-plugins/reviews/dashboard/custom-form-components.tsx
  21. 17 3
      packages/dev-server/test-plugins/reviews/dashboard/index.tsx
  22. 12 6
      packages/dev-server/test-plugins/reviews/dashboard/review-detail.tsx
  23. 11 6
      packages/dev-server/test-plugins/reviews/entities/product-review-translation.entity.ts
  24. 77 9
      packages/dev-server/test-plugins/reviews/reviews-plugin.ts

+ 157 - 45
packages/dashboard/src/lib/components/shared/custom-fields-form.tsx

@@ -1,7 +1,4 @@
-import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
-import { Control, ControllerRenderProps } from 'react-hook-form';
 import {
-    Form,
     FormControl,
     FormDescription,
     FormField,
@@ -10,12 +7,15 @@ import {
     FormMessage,
 } from '@/components/ui/form.js';
 import { Input } from '@/components/ui/input.js';
+import { CustomFormComponent } from '@/framework/form-engine/custom-form-component.js';
+import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
 import { useUserSettings } from '@/hooks/use-user-settings.js';
-import { Switch } from '../ui/switch.js';
-import { CustomFieldType } from '@vendure/common/lib/shared-types';
-import { TranslatableFormField } from './translatable-form-field.js';
 import { customFieldConfigFragment } from '@/providers/server-config.js';
+import { CustomFieldType } from '@vendure/common/lib/shared-types';
 import { ResultOf } from 'gql.tada';
+import { Control, ControllerRenderProps } from 'react-hook-form';
+import { Switch } from '../ui/switch.js';
+import { TranslatableFormField } from './translatable-form-field.js';
 
 type CustomFieldConfig = ResultOf<typeof customFieldConfigFragment>;
 
@@ -29,59 +29,171 @@ export function CustomFieldsForm({ entityType, control, formPathPrefix }: Custom
     const {
         settings: { displayLanguage },
     } = useUserSettings();
-    function getTranslation(input: Array<{ languageCode: string; value: string }> | null | undefined) {
+
+    const getTranslation = (input: Array<{ languageCode: string; value: string }> | null | undefined) => {
         return input?.find(t => t.languageCode === displayLanguage)?.value;
-    }
+    };
+
     const customFields = useCustomFieldConfig(entityType);
+
+    const getFieldName = (fieldDefName: string) => {
+        return formPathPrefix
+            ? `${formPathPrefix}.customFields.${fieldDefName}`
+            : `customFields.${fieldDefName}`;
+    };
+
     return (
         <div className="grid grid-cols-2 gap-4">
             {customFields?.map(fieldDef => (
-                <div key={fieldDef.name}>
-                    {fieldDef.type === 'localeString' || fieldDef.type === 'localeText' ? (
-                        <TranslatableFormField
-                            control={control}
-                            name={formPathPrefix ? `${formPathPrefix}.customFields.${fieldDef.name}` : `customFields.${fieldDef.name}`}
-                            render={({ field }) => (
-                                <FormItem>
-                                    <FormLabel>{getTranslation(fieldDef.label) ?? field.name}</FormLabel>
-                                    <FormControl>
-                                        {fieldDef.readonly ? field.value : <FormInputForType fieldDef={fieldDef} field={field} />}
-                                    </FormControl>
-                                </FormItem>
-                            )}
-                        />
-                    ) : (
-                        <FormField
-                            control={control}
-                            name={formPathPrefix ? `${formPathPrefix}.customFields.${fieldDef.name}` : `customFields.${fieldDef.name}`}
-                            render={({ field }) => (
-                            <FormItem>
-                                <FormLabel>{getTranslation(fieldDef.label) ?? field.name}</FormLabel>
-                                <FormControl>
-                                    {fieldDef.readonly ? field.value : <FormInputForType fieldDef={fieldDef} field={field} />}
-                                </FormControl>
-                                <FormDescription>{getTranslation(fieldDef.description)}</FormDescription>
-                                <FormMessage />
-                            </FormItem>
-                            )}
-                        />
-                    )}
-                </div>
+                <CustomFieldItem
+                    key={fieldDef.name}
+                    fieldDef={fieldDef}
+                    control={control}
+                    fieldName={getFieldName(fieldDef.name)}
+                    getTranslation={getTranslation}
+                />
             ))}
         </div>
     );
 }
 
-function FormInputForType({ fieldDef, field }: { fieldDef: CustomFieldConfig, field: ControllerRenderProps<any, any> }) {
-    switch (fieldDef.type as CustomFieldType) {    
+interface CustomFieldItemProps {
+    fieldDef: CustomFieldConfig;
+    control: Control<any, any>;
+    fieldName: string;
+    getTranslation: (
+        input: Array<{ languageCode: string; value: string }> | null | undefined,
+    ) => string | undefined;
+}
+
+function CustomFieldItem({ fieldDef, control, fieldName, getTranslation }: CustomFieldItemProps) {
+    const hasCustomFormComponent = fieldDef.ui && fieldDef.ui.component;
+    const isLocaleField = fieldDef.type === 'localeString' || fieldDef.type === 'localeText';
+
+    // For locale fields, always use TranslatableFormField regardless of custom components
+    if (isLocaleField) {
+        return (
+            <TranslatableFormField
+                control={control}
+                name={fieldName}
+                render={({ field, ...props }) => (
+                    <FormItem>
+                        <FormLabel>{getTranslation(fieldDef.label) ?? field.name}</FormLabel>
+                        <FormControl>
+                            {hasCustomFormComponent ? (
+                                <CustomFormComponent
+                                    fieldDef={fieldDef}
+                                    fieldProps={{
+                                        ...props,
+                                        field: {
+                                            ...field,
+                                            disabled: fieldDef.readonly ?? false,
+                                        },
+                                    }}
+                                />
+                            ) : (
+                                <FormInputForType fieldDef={fieldDef} field={field} />
+                            )}
+                        </FormControl>
+                        <FormDescription>{getTranslation(fieldDef.description)}</FormDescription>
+                        <FormMessage />
+                    </FormItem>
+                )}
+            />
+        );
+    }
+
+    // For non-locale fields with custom components
+    if (hasCustomFormComponent) {
+        return (
+            <FormField
+                control={control}
+                name={fieldName}
+                render={fieldProps => (
+                    <CustomFieldFormItem
+                        fieldDef={fieldDef}
+                        getTranslation={getTranslation}
+                        fieldName={fieldProps.field.name}
+                    >
+                        <CustomFormComponent
+                            fieldDef={fieldDef}
+                            fieldProps={{
+                                ...fieldProps,
+                                field: {
+                                    ...fieldProps.field,
+                                    disabled: fieldDef.readonly ?? false,
+                                },
+                            }}
+                        />
+                    </CustomFieldFormItem>
+                )}
+            />
+        );
+    }
+
+    // For regular fields without custom components
+    return (
+        <FormField
+            control={control}
+            name={fieldName}
+            render={({ field }) => (
+                <CustomFieldFormItem
+                    fieldDef={fieldDef}
+                    getTranslation={getTranslation}
+                    fieldName={field.name}
+                >
+                    <FormInputForType fieldDef={fieldDef} field={field} />
+                </CustomFieldFormItem>
+            )}
+        />
+    );
+}
+
+interface CustomFieldFormItemProps {
+    fieldDef: CustomFieldConfig;
+    getTranslation: (
+        input: Array<{ languageCode: string; value: string }> | null | undefined,
+    ) => string | undefined;
+    fieldName: string;
+    children: React.ReactNode;
+}
+
+function CustomFieldFormItem({ fieldDef, getTranslation, fieldName, children }: CustomFieldFormItemProps) {
+    return (
+        <FormItem>
+            <FormLabel>{getTranslation(fieldDef.label) ?? fieldName}</FormLabel>
+            <FormControl>{children}</FormControl>
+            <FormDescription>{getTranslation(fieldDef.description)}</FormDescription>
+            <FormMessage />
+        </FormItem>
+    );
+}
+
+function FormInputForType({
+    fieldDef,
+    field,
+}: {
+    fieldDef: CustomFieldConfig;
+    field: ControllerRenderProps<any, any>;
+}) {
+    const isReadonly = fieldDef.readonly ?? false;
+
+    switch (fieldDef.type as CustomFieldType) {
         case 'string':
-            return <Input {...field} />;
+            return <Input {...field} disabled={isReadonly} />;
         case 'float':
         case 'int':
-            return <Input type="number" {...field}  onChange={(e) => field.onChange(e.target.valueAsNumber)} />;
+            return (
+                <Input
+                    type="number"
+                    {...field}
+                    disabled={isReadonly}
+                    onChange={e => field.onChange(e.target.valueAsNumber)}
+                />
+            );
         case 'boolean':
-            return <Switch checked={field.value} onCheckedChange={field.onChange} />;
+            return <Switch checked={field.value} onCheckedChange={field.onChange} disabled={isReadonly} />;
         default:
-            return <Input {...field} />
+            return <Input {...field} disabled={isReadonly} />;
     }
 }

+ 18 - 13
packages/dashboard/src/lib/components/shared/customer-address-form.tsx

@@ -1,5 +1,12 @@
-import { Button } from '@vendure/dashboard';
-import { Checkbox } from '@vendure/dashboard';
+import { api } from '@/graphql/api.js';
+import { graphql } from '@/graphql/graphql.js';
+import { Trans, useLingui } from '@/lib/trans.js';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { useQuery } from '@tanstack/react-query';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+import { Button } from '../ui/button.js';
+import { Checkbox } from '../ui/checkbox.js';
 import {
     Form,
     FormControl,
@@ -8,16 +15,9 @@ import {
     FormItem,
     FormLabel,
     FormMessage,
-} from '@vendure/dashboard';
-import { Input } from '@vendure/dashboard';
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@vendure/dashboard';
-import { api } from '@vendure/dashboard';
-import { graphql } from '@vendure/dashboard';
-import { zodResolver } from '@hookform/resolvers/zod';
-import { Trans, useLingui } from '@/lib/trans.js';
-import { useQuery } from '@tanstack/react-query';
-import { useForm } from 'react-hook-form';
-import { z } from 'zod';
+} from '../ui/form.js';
+import { Input } from '../ui/input.js';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select.js';
 
 // Query document to fetch available countries
 const getAvailableCountriesDocument = graphql(`
@@ -58,7 +58,12 @@ interface CustomerAddressFormProps<T = any> {
     onCancel?: () => void;
 }
 
-export function CustomerAddressForm<T>({ address, setValuesForUpdate, onSubmit, onCancel }: CustomerAddressFormProps<T>) {
+export function CustomerAddressForm<T>({
+    address,
+    setValuesForUpdate,
+    onSubmit,
+    onCancel,
+}: CustomerAddressFormProps<T>) {
     const { i18n } = useLingui();
 
     // Fetch available countries

+ 2 - 2
packages/dashboard/src/lib/components/shared/logo-mark.tsx

@@ -6,8 +6,8 @@ export function LogoMark(props: React.ComponentProps<'svg'>) {
                 fill="currentColor"
             />
             <path
-                fill-rule="evenodd"
-                clip-rule="evenodd"
+                fillRule="evenodd"
+                clipRule="evenodd"
                 d="M174.388 4.798a2.148 2.148 0 0 0-2.136 2.157c0 1.191.957 2.158 2.136 2.158a2.149 2.149 0 0 0 2.137-2.158c0-1.19-.958-2.157-2.137-2.157Zm-2.611 2.157c0-1.456 1.169-2.637 2.611-2.637 1.443 0 2.612 1.181 2.612 2.637 0 1.457-1.169 2.638-2.612 2.638-1.442 0-2.611-1.181-2.611-2.638Z"
                 fill="currentColor"
             />

+ 2 - 8
packages/dashboard/src/lib/components/shared/translatable-form-field.tsx

@@ -1,13 +1,7 @@
-import { Controller } from 'react-hook-form';
-import { FieldPath } from 'react-hook-form';
 import { useUserSettings } from '@/hooks/use-user-settings.js';
-import { ControllerProps } from 'react-hook-form';
-import { FieldValues } from 'react-hook-form';
+import { Controller, ControllerProps, FieldPath, FieldValues } from 'react-hook-form';
+import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from '../ui/form.js';
 import { FormFieldWrapper } from './form-field-wrapper.js';
-import { FormMessage } from '../ui/form.js';
-import { FormControl } from '../ui/form.js';
-import { FormItem } from '../ui/form.js';
-import { FormDescription, FormField, FormLabel } from '../ui/form.js';
 
 export type TranslatableEntity = FieldValues & {
     translations?: Array<{ languageCode: string }> | null;

+ 43 - 10
packages/dashboard/src/lib/framework/document-introspection/get-document-structure.ts

@@ -199,6 +199,23 @@ function unwrapVariableDefinitionType(type: TypeNode): NamedTypeNode {
     return type;
 }
 
+/**
+ * @description
+ * Helper function to get the first field selection from a query operation definition.
+ */
+function getFirstQueryField(documentNode: DocumentNode): FieldNode {
+    const operationDefinition = documentNode.definitions.find(
+        (def): def is OperationDefinitionNode =>
+            def.kind === 'OperationDefinition' && def.operation === 'query',
+    );
+    const firstSelection = operationDefinition?.selectionSet.selections[0];
+    if (firstSelection?.kind === 'Field') {
+        return firstSelection;
+    } else {
+        throw new Error('Could not determine query field');
+    }
+}
+
 /**
  * @description
  * This function is used to get the name of the query from a DocumentNode.
@@ -216,16 +233,32 @@ function unwrapVariableDefinitionType(type: TypeNode): NamedTypeNode {
  * The query name is `product`.
  */
 export function getQueryName(documentNode: DocumentNode): string {
-    const operationDefinition = documentNode.definitions.find(
-        (def): def is OperationDefinitionNode =>
-            def.kind === 'OperationDefinition' && def.operation === 'query',
-    );
-    const firstSelection = operationDefinition?.selectionSet.selections[0];
-    if (firstSelection?.kind === 'Field') {
-        return firstSelection.name.value;
-    } else {
-        throw new Error('Could not determine query name');
-    }
+    const firstField = getFirstQueryField(documentNode);
+    return firstField.name.value;
+}
+
+/**
+ * @description
+ * This function is used to get the entity name from a DocumentNode.
+ *
+ * For example, in the following query:
+ *
+ * ```graphql
+ * query ProductDetail($id: ID!) {
+ *   product(id: $id) {
+ *     ...ProductDetail
+ *   }
+ * }
+ * ```
+ *
+ * The entity name is `Product`.
+ */
+export function getEntityName(documentNode: DocumentNode): string {
+    const firstField = getFirstQueryField(documentNode);
+    // Get the return type from the field definition
+    const fieldName = firstField.name.value;
+    const queryInfo = getQueryInfo(fieldName);
+    return queryInfo.type;
 }
 
 /**

+ 6 - 0
packages/dashboard/src/lib/framework/extension-api/define-dashboard-extension.ts

@@ -1,4 +1,5 @@
 import { registerDashboardWidget } from '../dashboard-widget/widget-extensions.js';
+import { addCustomFormComponent } from '../form-engine/custom-form-component-extensions.js';
 import {
     registerDashboardActionBarItem,
     registerDashboardPageBlock,
@@ -76,6 +77,11 @@ export function defineDashboardExtension(extension: DashboardExtension) {
                 registerDashboardWidget(widget);
             }
         }
+        if (extension.customFormComponents) {
+            for (const component of extension.customFormComponents) {
+                addCustomFormComponent(component);
+            }
+        }
         const callbacks = globalRegistry.get('extensionSourceChangeCallbacks');
         if (callbacks.size) {
             for (const callback of callbacks) {

+ 19 - 0
packages/dashboard/src/lib/framework/extension-api/extension-api-types.ts

@@ -5,8 +5,21 @@ import type React from 'react';
 
 import { DashboardAlertDefinition } from '../alert/types.js';
 import { DashboardWidgetDefinition } from '../dashboard-widget/types.js';
+import { CustomFormComponentInputProps } from '../form-engine/custom-form-component.js';
 import { NavMenuItem } from '../nav-menu/nav-menu-extensions.js';
 
+/**
+ * @description
+ * Allows you to define custom form components for custom fields in the dashboard.
+ *
+ * @docsCategory extensions
+ * @since 3.4.0
+ */
+export interface DashboardCustomFormComponent {
+    id: string;
+    component: React.FunctionComponent<CustomFormComponentInputProps>;
+}
+
 export interface DashboardRouteDefinition {
     component: (route: AnyRoute) => React.ReactNode;
     path: string;
@@ -137,4 +150,10 @@ export interface DashboardExtension {
      * given components and optionally also add a nav menu item.
      */
     widgets?: DashboardWidgetDefinition[];
+
+    /**
+     * @description
+     * Allows you to define custom form components for custom fields in the dashboard.
+     */
+    customFormComponents?: DashboardCustomFormComponent[];
 }

+ 28 - 0
packages/dashboard/src/lib/framework/form-engine/custom-form-component-extensions.ts

@@ -0,0 +1,28 @@
+import { DashboardCustomFormComponent } from '../extension-api/extension-api-types.js';
+import { globalRegistry } from '../registry/global-registry.js';
+
+import { CustomFormComponentInputProps } from './custom-form-component.js';
+
+globalRegistry.register(
+    'customFormComponents',
+    new Map<string, React.FunctionComponent<CustomFormComponentInputProps>>(),
+);
+
+export function getCustomFormComponents() {
+    return globalRegistry.get('customFormComponents');
+}
+
+export function getCustomFormComponent(
+    id: string,
+): React.FunctionComponent<CustomFormComponentInputProps> | undefined {
+    return globalRegistry.get('customFormComponents').get(id);
+}
+
+export function addCustomFormComponent({ id, component }: DashboardCustomFormComponent) {
+    const customFormComponents = globalRegistry.get('customFormComponents');
+    if (customFormComponents.has(id)) {
+        // eslint-disable-next-line no-console
+        console.warn(`Custom form component with id "${id}" is already registered and will be overwritten.`);
+    }
+    customFormComponents.set(id, component);
+}

+ 33 - 0
packages/dashboard/src/lib/framework/form-engine/custom-form-component.tsx

@@ -0,0 +1,33 @@
+import { CustomFieldConfig } from '@vendure/common/lib/generated-types';
+import {
+    ControllerFieldState,
+    ControllerRenderProps,
+    FieldPath,
+    FieldValues,
+    UseFormStateReturn,
+} from 'react-hook-form';
+import { getCustomFormComponent } from './custom-form-component-extensions.js';
+
+export interface CustomFormComponentProps {
+    fieldProps: CustomFormComponentInputProps;
+    fieldDef: Pick<CustomFieldConfig, 'ui' | 'type' | 'name'>;
+}
+
+export interface CustomFormComponentInputProps<
+    TFieldValues extends FieldValues = FieldValues,
+    TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
+> {
+    field: ControllerRenderProps<TFieldValues, TName>;
+    fieldState: ControllerFieldState;
+    formState: UseFormStateReturn<TFieldValues>;
+}
+
+export function CustomFormComponent({ fieldDef, fieldProps }: CustomFormComponentProps) {
+    const Component = getCustomFormComponent(fieldDef.ui?.component);
+
+    if (!Component) {
+        return null;
+    }
+
+    return <Component {...fieldProps} />;
+}

+ 50 - 11
packages/dashboard/src/lib/framework/form-engine/use-generated-form.tsx

@@ -13,14 +13,18 @@ import { useForm } from 'react-hook-form';
 
 export interface GeneratedFormOptions<
     T extends TypedDocumentNode<any, any>,
-    VarName extends (keyof VariablesOf<T>) | undefined = 'input',
+    VarName extends keyof VariablesOf<T> | undefined = 'input',
     E extends Record<string, any> = Record<string, any>,
 > {
     document?: T;
     varName?: VarName;
     entity: E | null | undefined;
-    setValues: (entity: NonNullable<E>) => VarName extends keyof VariablesOf<T> ? VariablesOf<T>[VarName] : VariablesOf<T>;
-    onSubmit?: (values: VarName extends keyof VariablesOf<T> ? VariablesOf<T>[VarName] : VariablesOf<T>) => void;
+    setValues: (
+        entity: NonNullable<E>,
+    ) => VarName extends keyof VariablesOf<T> ? VariablesOf<T>[VarName] : VariablesOf<T>;
+    onSubmit?: (
+        values: VarName extends keyof VariablesOf<T> ? VariablesOf<T>[VarName] : VariablesOf<T>,
+    ) => void;
 }
 
 /**
@@ -41,7 +45,7 @@ export function useGeneratedForm<
     const updateFields = document ? getOperationVariablesFields(document, varName) : [];
     const schema = createFormSchemaFromFields(updateFields);
     const defaultValues = getDefaultValuesFromFields(updateFields, activeChannel?.defaultLanguageCode);
-    const processedEntity = ensureTranslationsForAllLanguages(entity, availableLanguages);
+    const processedEntity = ensureTranslationsForAllLanguages(entity, availableLanguages, defaultValues);
 
     const form = useForm({
         resolver: async (values, context, options) => {
@@ -53,7 +57,7 @@ export function useGeneratedForm<
         },
         mode: 'onChange',
         defaultValues,
-        values: processedEntity ? setValues(processedEntity) : defaultValues,
+        values: processedEntity ? processedEntity : defaultValues,
     });
     let submitHandler = (event: FormEvent) => {
         event.preventDefault();
@@ -69,11 +73,13 @@ export function useGeneratedForm<
 
 /**
  * Ensures that an entity with translations has entries for all available languages.
- * If a language is missing, it creates an empty translation based on the structure of existing translations.
+ * If a language is missing, it creates an empty translation based on the structure of existing translations
+ * and the expected form structure from defaultValues.
  */
 function ensureTranslationsForAllLanguages<E extends Record<string, any>>(
     entity: E | null | undefined,
     availableLanguages: string[] = [],
+    expectedStructure?: Record<string, any>,
 ): E | null | undefined {
     if (
         !entity ||
@@ -91,23 +97,56 @@ function ensureTranslationsForAllLanguages<E extends Record<string, any>>(
     // Get existing language codes
     const existingLanguageCodes = new Set(translations.map((t: any) => t.languageCode));
 
+    // Get the expected translation structure from defaultValues or existing translations
+    const existingTemplate = translations[0] || {};
+    const expectedTranslationStructure = expectedStructure?.translations?.[0] || {};
+
+    // Merge the structures to ensure we have all expected fields
+    const templateStructure = {
+        ...expectedTranslationStructure,
+        ...existingTemplate,
+    };
+
     // Add missing language translations
     for (const langCode of availableLanguages) {
         if (!existingLanguageCodes.has(langCode)) {
-            // Find a translation to use as template for field structure
-            const template = translations[0] || {};
             const emptyTranslation: Record<string, any> = {
                 languageCode: langCode,
             };
 
-            // Add empty fields based on template (excluding languageCode)
-            Object.keys(template).forEach(key => {
+            // Add empty fields based on merged template structure (excluding languageCode)
+            Object.keys(templateStructure).forEach(key => {
                 if (key !== 'languageCode') {
-                    emptyTranslation[key] = '';
+                    if (typeof templateStructure[key] === 'object' && templateStructure[key] !== null) {
+                        // For nested objects like customFields, create an empty object
+                        emptyTranslation[key] = Array.isArray(templateStructure[key]) ? [] : {};
+                    } else {
+                        // For primitive values, use empty string as default
+                        emptyTranslation[key] = '';
+                    }
                 }
             });
 
             translations.push(emptyTranslation);
+        } else {
+            // For existing translations, ensure they have all expected fields
+            const existingTranslation = translations.find((t: any) => t.languageCode === langCode);
+            if (existingTranslation) {
+                Object.keys(expectedTranslationStructure).forEach(key => {
+                    if (key !== 'languageCode' && !(key in existingTranslation)) {
+                        if (
+                            typeof expectedTranslationStructure[key] === 'object' &&
+                            expectedTranslationStructure[key] !== null
+                        ) {
+                            existingTranslation[key] = Array.isArray(expectedTranslationStructure[key])
+                                ? []
+                                : {};
+                        } else {
+                            existingTranslation[key] = '';
+                        }
+                    }
+                });
+            }
         }
     }
 

+ 52 - 29
packages/dashboard/src/lib/framework/page/detail-page.tsx

@@ -10,8 +10,12 @@ import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import { AnyRoute, useNavigate } from '@tanstack/react-router';
 import { ResultOf, VariablesOf } from 'gql.tada';
 import { toast } from 'sonner';
-import { getOperationVariablesFields } from '../document-introspection/get-document-structure.js';
+import {
+    getEntityName,
+    getOperationVariablesFields,
+} from '../document-introspection/get-document-structure.js';
 
+import { TranslatableFormFieldWrapper } from '@/components/shared/translatable-form-field.js';
 import {
     CustomFieldsPageBlock,
     DetailFormGrid,
@@ -41,6 +45,7 @@ export interface DetailPageProps<
     /**
      * @description
      * The name of the entity.
+     * If not provided, it will be inferred from the query document.
      */
     entityName?: string;
     /**
@@ -80,6 +85,29 @@ export interface DetailPageProps<
     setValuesForUpdate: (entity: ResultOf<T>[EntityField]) => VariablesOf<U>['input'];
 }
 
+/**
+ * Renders form input components based on field type
+ */
+function renderFieldInput(fieldInfo: { type: string }, field: any) {
+    switch (fieldInfo.type) {
+        case 'Int':
+        case 'Float':
+            return (
+                <Input
+                    type="number"
+                    value={field.value}
+                    onChange={e => field.onChange(e.target.valueAsNumber)}
+                />
+            );
+        case 'DateTime':
+            return <DateTimeInput {...field} />;
+        case 'Boolean':
+            return <Checkbox value={field.value} onCheckedChange={field.onChange} />;
+        default:
+            return <Input {...field} />;
+    }
+}
+
 /**
  * @description
  * **Status: Developer Preview**
@@ -100,7 +128,7 @@ export function DetailPage<
 >({
     pageId,
     route,
-    entityName,
+    entityName: passedEntityName,
     queryDocument,
     createDocument,
     updateDocument,
@@ -110,11 +138,15 @@ export function DetailPage<
     const params = route.useParams();
     const creatingNewEntity = params.id === NEW_ENTITY_PATH;
     const navigate = useNavigate();
+    const inferredEntityName = getEntityName(queryDocument);
+
+    const entityName = passedEntityName ?? inferredEntityName;
 
     const { form, submitHandler, entity, isPending, resetForm } = useDetailPage<any, any, any>({
         queryDocument,
         updateDocument,
         createDocument,
+        entityName,
         params: { id: params.id },
         setValuesForUpdate,
         onSuccess: async data => {
@@ -133,6 +165,7 @@ export function DetailPage<
     });
 
     const updateFields = getOperationVariablesFields(updateDocument, 'input');
+    const translations = updateFields.find(fieldInfo => fieldInfo.name === 'translations');
 
     return (
         <Page pageId={pageId} form={form} submitHandler={submitHandler}>
@@ -152,6 +185,7 @@ export function DetailPage<
                     <DetailFormGrid>
                         {updateFields
                             .filter(fieldInfo => fieldInfo.name !== 'customFields')
+                            .filter(fieldInfo => fieldInfo.name !== 'translations')
                             .map(fieldInfo => {
                                 if (fieldInfo.name === 'id' && fieldInfo.type === 'ID') {
                                     return null;
@@ -162,33 +196,22 @@ export function DetailPage<
                                         control={form.control}
                                         name={fieldInfo.name as never}
                                         label={fieldInfo.name}
-                                        render={({ field }) => {
-                                            switch (fieldInfo.type) {
-                                                case 'Int':
-                                                case 'Float':
-                                                    return (
-                                                        <Input
-                                                            type="number"
-                                                            value={field.value}
-                                                            onChange={e =>
-                                                                field.onChange(e.target.valueAsNumber)
-                                                            }
-                                                        />
-                                                    );
-                                                case 'DateTime':
-                                                    return <DateTimeInput {...field} />;
-                                                case 'Boolean':
-                                                    return (
-                                                        <Checkbox
-                                                            value={field.value}
-                                                            onCheckedChange={field.onChange}
-                                                        />
-                                                    );
-                                                case 'String':
-                                                default:
-                                                    return <Input {...field} />;
-                                            }
-                                        }}
+                                        render={({ field }) => renderFieldInput(fieldInfo, field)}
+                                    />
+                                );
+                            })}
+                        {translations?.typeInfo
+                            ?.filter(
+                                fieldInfo => !['customFields', 'id', 'languageCode'].includes(fieldInfo.name),
+                            )
+                            .map(fieldInfo => {
+                                return (
+                                    <TranslatableFormFieldWrapper
+                                        key={fieldInfo.name}
+                                        control={form.control}
+                                        name={fieldInfo.name as never}
+                                        label={fieldInfo.name}
+                                        render={({ field }) => renderFieldInput(fieldInfo, field)}
                                     />
                                 );
                             })}

+ 18 - 2
packages/dashboard/src/lib/framework/page/use-detail-page.ts

@@ -1,5 +1,7 @@
 import { NEW_ENTITY_PATH } from '@/constants.js';
 import { api, Variables } from '@/graphql/api.js';
+import { useCustomFieldConfig } from '@/hooks/use-custom-field-config.js';
+import { removeReadonlyCustomFields } from '@/lib/utils.js';
 import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
 import {
     DefinedInitialDataOptions,
@@ -59,6 +61,12 @@ export interface DetailPageOptions<
     params: {
         id: string;
     };
+    /**
+     * @description
+     * The entity type name for custom field configuration lookup.
+     * Required to filter out readonly custom fields before mutations.
+     */
+    entityName: string;
     /**
      * @description
      * The document to create the entity.
@@ -232,16 +240,19 @@ export function useDetailPage<
         transformUpdateInput,
         params,
         entityField,
+        entityName,
         onSuccess,
         onError,
     } = options;
     const isNew = params.id === NEW_ENTITY_PATH;
     const queryClient = useQueryClient();
+    const customFieldConfig = useCustomFieldConfig(entityName);
     const detailQueryOptions = getDetailQueryOptions(addCustomFields(queryDocument), {
         id: isNew ? '__NEW__' : params.id,
     });
     const detailQuery = useSuspenseQuery(detailQueryOptions);
     const entityQueryField = entityField ?? getQueryName(queryDocument);
+
     const entity = (detailQuery?.data as any)[entityQueryField] as
         | DetailPageEntity<T, EntityField>
         | undefined;
@@ -280,10 +291,15 @@ export function useDetailPage<
         entity,
         setValues: setValuesForUpdate,
         onSubmit(values: any) {
+            // Filter out readonly custom fields before submitting
+            const filteredValues = removeReadonlyCustomFields(values, customFieldConfig || []);
+
             if (isNew) {
-                createMutation.mutate({ input: transformCreateInput?.(values) ?? values });
+                const finalInput = transformCreateInput?.(filteredValues) ?? filteredValues;
+                createMutation.mutate({ input: finalInput });
             } else {
-                updateMutation.mutate({ input: transformUpdateInput?.(values) ?? values });
+                const finalInput = transformUpdateInput?.(filteredValues) ?? filteredValues;
+                updateMutation.mutate({ input: finalInput });
             }
         },
     });

+ 2 - 0
packages/dashboard/src/lib/framework/registry/registry-types.ts

@@ -4,6 +4,7 @@ import {
     DashboardActionBarItem,
     DashboardPageBlockDefinition,
 } from '../extension-api/extension-api-types.js';
+import { CustomFormComponentInputProps } from '../form-engine/custom-form-component.js';
 import { NavMenuConfig } from '../nav-menu/nav-menu-extensions.js';
 
 export interface GlobalRegistryContents {
@@ -14,6 +15,7 @@ export interface GlobalRegistryContents {
     dashboardPageBlockRegistry: Map<string, DashboardPageBlockDefinition[]>;
     dashboardWidgetRegistry: Map<string, DashboardWidgetDefinition>;
     dashboardAlertRegistry: Map<string, DashboardAlertDefinition>;
+    customFormComponents: Map<string, React.FunctionComponent<CustomFormComponentInputProps>>;
 }
 
 export type GlobalRegistryKey = keyof GlobalRegistryContents;

File diff suppressed because it is too large
+ 0 - 0
packages/dashboard/src/lib/graphql/graphql-env.d.ts


+ 29 - 1
packages/dashboard/src/lib/index.ts

@@ -1,8 +1,8 @@
 // This file is auto-generated. Do not edit manually.
-// Generated on: 2025-03-28T08:23:55.325Z
 
 export * from './components/data-display/boolean.js';
 export * from './components/data-display/date-time.js';
+export * from './components/data-display/json.js';
 export * from './components/data-display/money.js';
 export * from './components/data-input/affixed-input.js';
 export * from './components/data-input/customer-group-input.js';
@@ -10,12 +10,22 @@ export * from './components/data-input/datetime-input.js';
 export * from './components/data-input/facet-value-input.js';
 export * from './components/data-input/money-input.js';
 export * from './components/data-input/richt-text-input.js';
+export * from './components/data-table/add-filter-menu.js';
 export * from './components/data-table/data-table-column-header.js';
 export * from './components/data-table/data-table-faceted-filter.js';
+export * from './components/data-table/data-table-filter-badge.js';
 export * from './components/data-table/data-table-filter-dialog.js';
 export * from './components/data-table/data-table-pagination.js';
+export * from './components/data-table/data-table-types.js';
 export * from './components/data-table/data-table-view-options.js';
 export * from './components/data-table/data-table.js';
+export * from './components/data-table/filters/data-table-boolean-filter.js';
+export * from './components/data-table/filters/data-table-datetime-filter.js';
+export * from './components/data-table/filters/data-table-id-filter.js';
+export * from './components/data-table/filters/data-table-number-filter.js';
+export * from './components/data-table/filters/data-table-string-filter.js';
+export * from './components/data-table/human-readable-operator.js';
+export * from './components/data-table/refresh-button.js';
 export * from './components/layout/app-layout.js';
 export * from './components/layout/app-sidebar.js';
 export * from './components/layout/channel-switcher.js';
@@ -25,13 +35,17 @@ export * from './components/layout/language-dialog.js';
 export * from './components/layout/nav-main.js';
 export * from './components/layout/nav-projects.js';
 export * from './components/layout/nav-user.js';
+export * from './components/layout/prerelease-popup.js';
 export * from './components/login/login-form.js';
 export * from './components/shared/alerts.js';
 export * from './components/shared/animated-number.js';
+export * from './components/shared/asset/asset-focal-point-editor.js';
 export * from './components/shared/asset/asset-gallery.js';
 export * from './components/shared/asset/asset-picker-dialog.js';
 export * from './components/shared/asset/asset-preview-dialog.js';
+export * from './components/shared/asset/asset-preview-selector.js';
 export * from './components/shared/asset/asset-preview.js';
+export * from './components/shared/asset/asset-properties.js';
 export * from './components/shared/asset/focal-point-control.js';
 export * from './components/shared/assigned-facet-values.js';
 export * from './components/shared/channel-code-label.js';
@@ -43,6 +57,7 @@ export * from './components/shared/copyable-text.js';
 export * from './components/shared/country-selector.js';
 export * from './components/shared/currency-selector.js';
 export * from './components/shared/custom-fields-form.js';
+export * from './components/shared/customer-address-form.js';
 export * from './components/shared/customer-group-chip.js';
 export * from './components/shared/customer-group-selector.js';
 export * from './components/shared/customer-selector.js';
@@ -61,8 +76,11 @@ export * from './components/shared/icon-mark.js';
 export * from './components/shared/language-selector.js';
 export * from './components/shared/logo-mark.js';
 export * from './components/shared/multi-select.js';
+export * from './components/shared/navigation-confirmation.js';
+export * from './components/shared/option-value-input.js';
 export * from './components/shared/paginated-list-data-table.js';
 export * from './components/shared/permission-guard.js';
+export * from './components/shared/product-variant-selector.js';
 export * from './components/shared/rich-text-editor.js';
 export * from './components/shared/role-code-label.js';
 export * from './components/shared/role-selector.js';
@@ -103,6 +121,10 @@ export * from './components/ui/table.js';
 export * from './components/ui/tabs.js';
 export * from './components/ui/textarea.js';
 export * from './components/ui/tooltip.js';
+export * from './framework/alert/alert-extensions.js';
+export * from './framework/alert/alert-item.js';
+export * from './framework/alert/alerts-indicator.js';
+export * from './framework/alert/types.js';
 export * from './framework/component-registry/component-registry.js';
 export * from './framework/component-registry/dynamic-component.js';
 export * from './framework/dashboard-widget/base-widget.js';
@@ -122,11 +144,14 @@ export * from './framework/document-introspection/hooks.js';
 export * from './framework/extension-api/define-dashboard-extension.js';
 export * from './framework/extension-api/extension-api-types.js';
 export * from './framework/extension-api/use-dashboard-extensions.js';
+export * from './framework/form-engine/custom-form-component-extensions.js';
+export * from './framework/form-engine/custom-form-component.js';
 export * from './framework/form-engine/form-schema-tools.js';
 export * from './framework/form-engine/use-generated-form.js';
 export * from './framework/layout-engine/layout-extensions.js';
 export * from './framework/layout-engine/location-wrapper.js';
 export * from './framework/layout-engine/page-layout.js';
+export * from './framework/layout-engine/page-provider.js';
 export * from './framework/nav-menu/nav-menu-extensions.js';
 export * from './framework/page/detail-page-route-loader.js';
 export * from './framework/page/detail-page.js';
@@ -135,6 +160,8 @@ export * from './framework/page/page-api.js';
 export * from './framework/page/page-types.js';
 export * from './framework/page/use-detail-page.js';
 export * from './framework/page/use-extended-router.js';
+export * from './framework/registry/global-registry.js';
+export * from './framework/registry/registry-types.js';
 export * from './hooks/use-auth.js';
 export * from './hooks/use-channel.js';
 export * from './hooks/use-custom-field-config.js';
@@ -146,4 +173,5 @@ export * from './hooks/use-permissions.js';
 export * from './hooks/use-server-config.js';
 export * from './hooks/use-theme.js';
 export * from './hooks/use-user-settings.js';
+export * from './lib/trans.js';
 export * from './lib/utils.js';

+ 49 - 0
packages/dashboard/src/lib/lib/utils.ts

@@ -58,3 +58,52 @@ export function normalizeString(input: string, spaceReplacer = ' '): string {
         .replace(/\s+/g, spaceReplacer)
         .replace(multipleSequentialReplacerRegex, spaceReplacer);
 }
+
+/**
+ * Removes any readonly custom fields from form values before submission.
+ * This prevents errors when submitting readonly custom field values to mutations.
+ *
+ * @param values - The form values that may contain custom fields
+ * @param customFieldConfigs - Array of custom field configurations for the entity
+ * @returns The values with readonly custom fields removed
+ */
+export function removeReadonlyCustomFields<T extends Record<string, any>>(
+    values: T,
+    customFieldConfigs: Array<{ name: string; readonly?: boolean | null }> = [],
+): T {
+    if (!values || !customFieldConfigs?.length) {
+        return values;
+    }
+
+    // Create a deep copy to avoid mutating the original values
+    const result = structuredClone(values);
+
+    // Get readonly field names
+    const readonlyFieldNames = customFieldConfigs
+        .filter(config => config.readonly === true)
+        .map(config => config.name);
+
+    if (readonlyFieldNames.length === 0) {
+        return result;
+    }
+
+    // Remove readonly fields from main customFields
+    if (result.customFields && typeof result.customFields === 'object') {
+        for (const fieldName of readonlyFieldNames) {
+            delete result.customFields[fieldName];
+        }
+    }
+
+    // Remove readonly fields from translations customFields
+    if (Array.isArray(result.translations)) {
+        for (const translation of result.translations) {
+            if (translation?.customFields && typeof translation.customFields === 'object') {
+                for (const fieldName of readonlyFieldNames) {
+                    delete translation.customFields[fieldName];
+                }
+            }
+        }
+    }
+
+    return result;
+}

File diff suppressed because it is too large
+ 0 - 0
packages/dev-server/graphql/graphql-env.d.ts


+ 13 - 0
packages/dev-server/test-plugins/reviews/api/api-extensions.ts

@@ -1,6 +1,12 @@
 import { gql } from 'graphql-tag';
 
 export const commonApiExtensions = gql`
+    type ProductReviewTranslation {
+        id: ID!
+        languageCode: LanguageCode!
+        text: String!
+    }
+
     type ProductReview implements Node {
         id: ID!
         createdAt: DateTime!
@@ -17,6 +23,7 @@ export const commonApiExtensions = gql`
         state: String!
         response: String
         responseCreatedAt: DateTime
+        translations: [ProductReviewTranslation!]!
     }
 
     type ProductReviewList implements PaginatedList {
@@ -41,11 +48,17 @@ export const commonApiExtensions = gql`
 export const adminApiExtensions = gql`
     ${commonApiExtensions}
 
+    input ProductReviewTranslationInput {
+        languageCode: LanguageCode!
+        text: String!
+    }
+
     input UpdateProductReviewInput {
         id: ID!
         summary: String
         body: String
         response: String
+        translations: [ProductReviewTranslationInput!]!
     }
 
     extend type ProductReview {

+ 23 - 8
packages/dev-server/test-plugins/reviews/api/product-review-admin.resolver.ts

@@ -2,15 +2,18 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
     Allow,
     Ctx,
+    EntityNotFoundError,
     ListQueryBuilder,
-    patchEntity,
     Permission,
     Product,
     RequestContext,
     Transaction,
     TransactionalConnection,
+    TranslatableSaver,
+    translateDeep,
 } from '@vendure/core';
 
+import { ProductReviewTranslation } from '../entities/product-review-translation.entity';
 import { ProductReview } from '../entities/product-review.entity';
 import {
     MutationApproveProductReviewArgs,
@@ -22,7 +25,11 @@ import {
 
 @Resolver()
 export class ProductReviewAdminResolver {
-    constructor(private connection: TransactionalConnection, private listQueryBuilder: ListQueryBuilder) {}
+    constructor(
+        private connection: TransactionalConnection,
+        private listQueryBuilder: ListQueryBuilder,
+        private translatableSaver: TranslatableSaver,
+    ) {}
 
     @Query()
     @Allow(Permission.ReadCatalog)
@@ -42,7 +49,7 @@ export class ProductReviewAdminResolver {
     @Query()
     @Allow(Permission.ReadCatalog)
     async productReview(@Ctx() ctx: RequestContext, @Args() args: QueryProductReviewArgs) {
-        return this.connection.getRepository(ctx, ProductReview).findOne({
+        const review = await this.connection.getRepository(ctx, ProductReview).findOne({
             where: { id: args.id },
             relations: {
                 author: true,
@@ -50,6 +57,12 @@ export class ProductReviewAdminResolver {
                 productVariant: true,
             },
         });
+
+        if (!review) {
+            throw new EntityNotFoundError(ProductReview.name, args.id);
+        }
+
+        return translateDeep(review, ctx.languageCode);
     }
 
     @Transaction()
@@ -61,11 +74,13 @@ export class ProductReviewAdminResolver {
     ) {
         const review = await this.connection.getEntityOrThrow(ctx, ProductReview, input.id);
         const originalResponse = review.response;
-        const updatedProductReview = patchEntity(review, input);
-        if (input.response !== originalResponse) {
-            updatedProductReview.responseCreatedAt = new Date();
-        }
-        return this.connection.getRepository(ctx, ProductReview).save(updatedProductReview);
+
+        return this.translatableSaver.update({
+            ctx,
+            input,
+            entityType: ProductReview,
+            translationType: ProductReviewTranslation,
+        });
     }
 
     @Transaction()

+ 5 - 0
packages/dev-server/test-plugins/reviews/dashboard/custom-form-components.tsx

@@ -0,0 +1,5 @@
+import { CustomFormComponentInputProps, Textarea } from '@vendure/dashboard';
+
+export function TextareaCustomField({ field }: CustomFormComponentInputProps) {
+    return <Textarea {...field} rows={4} />;
+}

+ 17 - 3
packages/dev-server/test-plugins/reviews/dashboard/index.tsx

@@ -1,5 +1,6 @@
 import { Button, defineDashboardExtension } from '@vendure/dashboard';
 
+import { TextareaCustomField } from './custom-form-components';
 import { CustomWidget } from './custom-widget';
 import { reviewDetail } from './review-detail';
 import { reviewList } from './review-list';
@@ -18,9 +19,16 @@ export default defineDashboardExtension({
         {
             label: 'Custom Action Bar Item',
             component: props => {
-                return <Button type="button" onClick={() => {
-                    console.log('Clicked custom action bar item');
-                }}>Test Button</Button>;
+                return (
+                    <Button
+                        type="button"
+                        onClick={() => {
+                            console.log('Clicked custom action bar item');
+                        }}
+                    >
+                        Test Button
+                    </Button>
+                );
             },
             locationId: 'product-detail',
         },
@@ -39,4 +47,10 @@ export default defineDashboardExtension({
             },
         },
     ],
+    customFormComponents: [
+        {
+            id: 'textarea',
+            component: TextareaCustomField,
+        },
+    ],
 });

+ 12 - 6
packages/dev-server/test-plugins/reviews/dashboard/review-detail.tsx

@@ -26,7 +26,16 @@ const reviewDetailDocument = graphql(`
             state
             response
             responseCreatedAt
+            translations {
+                id
+                languageCode
+                text
+                customFields {
+                    reviewerName
+                }
+            }
             customFields {
+                verifiedReviewerName
                 reviewerName
             }
         }
@@ -53,7 +62,6 @@ export const reviewDetail: DashboardRouteDefinition = {
     component: route => {
         return (
             <DetailPage
-                entityName="ProductReview"
                 pageId="review-detail"
                 queryDocument={reviewDetailDocument}
                 updateDocument={updateReviewDocument}
@@ -64,11 +72,9 @@ export const reviewDetail: DashboardRouteDefinition = {
                         id: review.id,
                         summary: review.summary,
                         body: review.body,
-                        rating: review.rating,
-                        authorName: review.authorName,
-                        authorLocation: review.authorLocation,
-                        upvotes: review.upvotes,
-                        downvotes: review.downvotes,
+                        response: review.response,
+                        customFields: review.customFields,
+                        translations: review.translations,
                     };
                 }}
             />

+ 11 - 6
packages/dev-server/test-plugins/reviews/entities/product-review-translation.entity.ts

@@ -1,12 +1,17 @@
 import { DeepPartial } from '@vendure/common/lib/shared-types';
-import { LanguageCode, Translation, VendureEntity } from '@vendure/core';
+import { HasCustomFields, LanguageCode, Translation, VendureEntity } from '@vendure/core';
 import { Column, Entity, Index, ManyToOne } from 'typeorm';
 
-import { CustomReviewFields, ProductReview } from './product-review.entity';
+import { ProductReview } from './product-review.entity';
+
+export class CustomReviewFieldsTranslation {}
 
 @Entity()
-export class ProductReviewTranslation extends VendureEntity implements Translation<ProductReview> {
-    constructor(input?: DeepPartial<Translation<ProductReviewTranslation>>) {
+export class ProductReviewTranslation
+    extends VendureEntity
+    implements Translation<ProductReview>, HasCustomFields
+{
+    constructor(input?: DeepPartial<ProductReviewTranslation>) {
         super(input);
     }
 
@@ -20,6 +25,6 @@ export class ProductReviewTranslation extends VendureEntity implements Translati
     @ManyToOne(() => ProductReview, base => base.translations, { onDelete: 'CASCADE' })
     base: ProductReview;
 
-    @Column(type => CustomReviewFields)
-    customFields: CustomReviewFields;
+    @Column(type => CustomReviewFieldsTranslation)
+    customFields: CustomReviewFieldsTranslation;
 }

+ 77 - 9
packages/dev-server/test-plugins/reviews/reviews-plugin.ts

@@ -6,6 +6,7 @@ import {
     ProductVariant,
     RequestContextService,
     TransactionalConnection,
+    TranslatableSaver,
     VendurePlugin,
 } from '@vendure/core';
 import { AdminUiExtension } from '@vendure/ui-devkit/compiler';
@@ -18,7 +19,6 @@ import { ProductReviewEntityResolver } from './api/product-review-entity.resolve
 import { ProductReviewShopResolver } from './api/product-review-shop.resolver';
 import { ProductReviewTranslation } from './entities/product-review-translation.entity';
 import { ProductReview } from './entities/product-review.entity';
-import { ReviewState } from './types';
 
 @VendurePlugin({
     imports: [PluginCommonModule],
@@ -57,15 +57,31 @@ import { ReviewState } from './types';
             ui: { tab: 'Reviews', component: 'review-selector-form-input' },
             inverseSide: undefined,
         });
+        config.customFields.Product.push({
+            name: 'translatableText',
+            label: [{ languageCode: LanguageCode.en, value: 'Translatable text' }],
+            public: true,
+            type: 'localeText',
+        });
+
         config.customFields.ProductReview = [
             {
-                type: 'string',
+                type: 'localeText',
                 name: 'reviewerName',
                 label: [{ languageCode: LanguageCode.en, value: 'Reviewer name' }],
                 public: true,
                 nullable: true,
+                ui: { component: 'textarea' },
             },
         ];
+        config.customFields.ProductReview.push({
+            name: 'verifiedReviewerName',
+            label: [{ languageCode: LanguageCode.en, value: 'Verified reviewer name' }],
+            public: true,
+            nullable: true,
+            type: 'string',
+            readonly: true,
+        });
         return config;
     },
     dashboard: './dashboard/index.tsx',
@@ -74,6 +90,7 @@ export class ReviewsPlugin implements OnApplicationBootstrap {
     constructor(
         private readonly connection: TransactionalConnection,
         private readonly requestContextService: RequestContextService,
+        private readonly translatableSaver: TranslatableSaver,
     ) {}
 
     static uiExtensions: AdminUiExtension = {
@@ -103,8 +120,17 @@ export class ReviewsPlugin implements OnApplicationBootstrap {
                     authorName: 'John Smith',
                     authorLocation: 'New York, USA',
                     state: 'approved',
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            text: 'Great product, highly recommend!',
+                            customFields: {
+                                reviewerName: 'JSmith123',
+                            },
+                        },
+                    ],
                     customFields: {
-                        reviewerName: 'JSmith123',
+                        verifiedReviewerName: 'JSmith123 verified',
                     },
                 },
                 {
@@ -114,8 +140,17 @@ export class ReviewsPlugin implements OnApplicationBootstrap {
                     authorName: 'Sarah Wilson',
                     authorLocation: 'London, UK',
                     state: 'approved',
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            text: 'Good value for money',
+                            customFields: {
+                                reviewerName: 'SarahW',
+                            },
+                        },
+                    ],
                     customFields: {
-                        reviewerName: 'SarahW',
+                        verifiedReviewerName: 'SarahW verified',
                     },
                 },
                 {
@@ -125,8 +160,17 @@ export class ReviewsPlugin implements OnApplicationBootstrap {
                     authorName: 'Mike Johnson',
                     authorLocation: 'Toronto, Canada',
                     state: 'approved',
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            text: 'Decent but could be better',
+                            customFields: {
+                                reviewerName: 'MikeJ',
+                            },
+                        },
+                    ],
                     customFields: {
-                        reviewerName: 'MikeJ',
+                        verifiedReviewerName: 'MikeJ verified',
                     },
                 },
                 {
@@ -136,8 +180,17 @@ export class ReviewsPlugin implements OnApplicationBootstrap {
                     authorName: 'Emma Brown',
                     authorLocation: 'Sydney, Australia',
                     state: 'approved',
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            text: 'Exceeded expectations',
+                            customFields: {
+                                reviewerName: 'EmmaB',
+                            },
+                        },
+                    ],
                     customFields: {
-                        reviewerName: 'EmmaB',
+                        verifiedReviewerName: 'EmmaB verified',
                     },
                 },
                 {
@@ -147,8 +200,17 @@ export class ReviewsPlugin implements OnApplicationBootstrap {
                     authorName: 'David Lee',
                     authorLocation: 'Singapore',
                     state: 'approved',
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            text: 'Good product, fast delivery',
+                            customFields: {
+                                reviewerName: 'DavidL',
+                            },
+                        },
+                    ],
                     customFields: {
-                        reviewerName: 'DavidL',
+                        verifiedReviewerName: 'DavidL verified',
                     },
                 },
             ];
@@ -160,11 +222,17 @@ export class ReviewsPlugin implements OnApplicationBootstrap {
                 });
                 const randomVariant = productVariants[Math.floor(Math.random() * productVariants.length)];
 
-                await this.connection.getRepository(ctx, ProductReview).save({
+                const input = {
                     ...review,
-                    state: review.state as ReviewState,
                     product: randomProduct,
                     productVariant: randomVariant,
+                };
+
+                const createdReview = await this.translatableSaver.create({
+                    ctx,
+                    input,
+                    entityType: ProductReview,
+                    translationType: ProductReviewTranslation,
                 });
             }
         }

Some files were not shown because too many files changed in this diff