瀏覽代碼

feat(core): Implement support for struct custom field type (#3178)

Michael Bromley 1 年之前
父節點
當前提交
dffd123718
共有 63 個文件被更改,包括 3297 次插入498 次删除
  1. 0 3
      .graphqlconfig
  2. 106 0
      docs/docs/guides/developer-guide/custom-fields/index.md
  3. 99 15
      docs/docs/reference/admin-ui-api/custom-input-components/default-inputs.md
  4. 19 19
      docs/docs/reference/core-plugins/email-plugin/email-plugin-options.md
  5. 199 1
      docs/docs/reference/graphql-api/admin/object-types.md
  6. 199 1
      docs/docs/reference/graphql-api/shop/object-types.md
  7. 6 6
      docs/docs/reference/typescript-api/auth/default-session-cache-strategy.md
  8. 36 36
      docs/docs/reference/typescript-api/auth/external-authentication-service.md
  9. 1 1
      docs/docs/reference/typescript-api/common/admin-ui/admin-ui-app-config.md
  10. 1 1
      docs/docs/reference/typescript-api/common/admin-ui/admin-ui-app-dev-mode-config.md
  11. 1 1
      docs/docs/reference/typescript-api/common/admin-ui/admin-ui-config.md
  12. 1 1
      docs/docs/reference/typescript-api/common/currency-code.md
  13. 1 1
      docs/docs/reference/typescript-api/common/job-state.md
  14. 1 1
      docs/docs/reference/typescript-api/common/language-code.md
  15. 1 1
      docs/docs/reference/typescript-api/common/permission.md
  16. 1 1
      docs/docs/reference/typescript-api/configurable-operation-def/config-arg-type.md
  17. 2 1
      docs/docs/reference/typescript-api/configurable-operation-def/default-form-component-id.md
  18. 7 1
      docs/docs/reference/typescript-api/configurable-operation-def/default-form-config-hash.md
  19. 2 1
      docs/docs/reference/typescript-api/custom-fields/custom-field-config.md
  20. 4 2
      docs/docs/reference/typescript-api/custom-fields/custom-field-type.md
  21. 1 1
      docs/docs/reference/typescript-api/custom-fields/index.md
  22. 25 0
      docs/docs/reference/typescript-api/custom-fields/struct-custom-field-config.md
  23. 45 0
      docs/docs/reference/typescript-api/custom-fields/struct-field-config.md
  24. 1 1
      docs/docs/reference/typescript-api/custom-fields/typed-custom-single-field-config.md
  25. 10 10
      docs/docs/reference/typescript-api/testing/simple-graph-qlclient.md
  26. 2 0
      graphql.config.yml
  27. 113 2
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  28. 260 328
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  29. 2 0
      packages/admin-ui/src/lib/core/src/common/utilities/custom-field-default-value.ts
  30. 52 0
      packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts
  31. 14 1
      packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.ts
  32. 2 1
      packages/admin-ui/src/lib/core/src/providers/data-table/data-table-filter-collection.ts
  33. 1 0
      packages/admin-ui/src/lib/core/src/providers/data-table/data-table-sort-collection.ts
  34. 1 0
      packages/admin-ui/src/lib/core/src/public_api.ts
  35. 35 14
      packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table-custom-field-column.component.html
  36. 1 0
      packages/admin-ui/src/lib/core/src/shared/components/tabbed-custom-fields/tabbed-custom-fields.component.ts
  37. 2 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/default-form-inputs.ts
  38. 3 1
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts
  39. 21 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/struct-form-input/struct-form-input.component.html
  40. 15 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/struct-form-input/struct-form-input.component.scss
  41. 62 0
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/struct-form-input/struct-form-input.component.ts
  42. 2 0
      packages/admin-ui/src/lib/core/src/shared/shared.module.ts
  43. 109 0
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  44. 116 0
      packages/common/src/generated-shop-types.ts
  45. 110 1
      packages/common/src/generated-types.ts
  46. 9 3
      packages/common/src/shared-types.ts
  47. 448 0
      packages/core/e2e/custom-field-struct.e2e-spec.ts
  48. 112 0
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  49. 109 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  50. 38 4
      packages/core/src/api/common/validate-custom-field-value.ts
  51. 85 8
      packages/core/src/api/config/generate-resolvers.ts
  52. 118 23
      packages/core/src/api/config/graphql-custom-fields.ts
  53. 18 2
      packages/core/src/api/resolvers/admin/global-settings.resolver.ts
  54. 99 0
      packages/core/src/api/schema/common/custom-field-types.graphql
  55. 108 3
      packages/core/src/config/custom-field/custom-field-types.ts
  56. 17 1
      packages/core/src/entity/register-custom-entity-fields.ts
  57. 109 0
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  58. 109 0
      packages/payments-plugin/e2e/graphql/generated-admin-types.ts
  59. 109 0
      packages/payments-plugin/e2e/graphql/generated-shop-types.ts
  60. 116 0
      packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts
  61. 0 0
      schema-admin.json
  62. 0 0
      schema-shop.json
  63. 1 0
      scripts/codegen/generate-graphql-types.ts

+ 0 - 3
.graphqlconfig

@@ -1,3 +0,0 @@
-{
-    "schema": "./schema-admin.json"
-}

+ 106 - 0
docs/docs/guides/developer-guide/custom-fields/index.md

@@ -146,6 +146,7 @@ The following types are available for custom fields:
 | `float`        | Floating point number        | product review rating                                    |
 | `boolean`      | Boolean                      | isDownloadable flag on product                           |
 | `datetime`     | A datetime                   | date that variant is back in stock                       |
+| `struct`       | Structured json-like data    | Key-value attributes with additional data for products   |
 | `relation`     | A relation to another entity | Asset used as a customer avatar, related Products        |
 
 To see the underlying DB data type and GraphQL type used for each, see the [CustomFieldType doc](/reference/typescript-api/custom-fields/custom-field-type).
@@ -882,6 +883,111 @@ const config = {
 
 The step value. See [the MDN datetime-local docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#step) to understand how this is used.
 
+### Properties for `struct` fields
+
+:::info
+The `struct` custom field type is available from Vendure v3.1.0.
+:::
+
+In addition to the common properties, the `struct` custom fields have some type-specific properties:
+
+- [`fields`](#fields)
+
+#### fields
+
+<CustomFieldProperty required={true} type="StructFieldConfig[]" typeLink="/reference/typescript-api/custom-fields/struct-field-config" />
+
+A `struct` is a data structure comprising a set of named fields, each with its own type. The `fields` property is an array of `StructFieldConfig` objects, each of which defines a field within the struct.
+
+```ts title="src/vendure-config.ts"
+const config = {
+    // ...
+    customFields: {
+        Product: [
+            {
+                name: 'dimensions',
+                type: 'struct',
+                // highlight-start
+                fields: [
+                    { name: 'length', type: 'int' },
+                    { name: 'width', type: 'int' },
+                    { name: 'height', type: 'int' },
+                ],
+                // highlight-end
+            },
+        ]
+    }
+};
+```
+
+When querying the `Product` entity, the `dimensions` field will be an object with the fields `length`, `width` and `height`:
+
+```graphql
+query {
+    product(id: 1) {
+        customFields {
+            dimensions {
+                length
+                width
+                height
+            }
+        }
+    }
+}
+```
+
+Struct fields support many of the same properties as other custom fields, such as `list`, `label`, `description`, `validate`, `ui` and 
+type-specific properties such as `options` and `pattern` for string types.
+
+:::note
+The following properties are **not** supported for `struct` fields: `public`, `readonly`, `internal`, `defaultValue`, `nullable`, `unique`, `requiresPermission`.
+:::
+
+```ts title="src/vendure-config.ts"
+import { LanguageCode } from '@vendure/core';
+
+const config = {
+    // ...
+    customFields: {
+        OrderLine: [
+            {
+                name: 'customizationOptions',
+                type: 'struct',
+                fields: [
+                    {
+                        name: 'color',
+                        type: 'string',
+                        // highlight-start
+                        options: [
+                            {value: 'red', label: [{languageCode: LanguageCode.en, value: 'Red'}]},
+                            {value: 'blue', label: [{languageCode: LanguageCode.en, value: 'Blue'}]},
+                        ],
+                        // highlight-end
+                    },
+                    {
+                        name: 'engraving',
+                        type: 'string',
+                        // highlight-start
+                        validate: (value: any) => {
+                            if (value.length > 20) {
+                                return 'Engraving text must be 20 characters or fewer';
+                            }
+                        },
+                    },
+                    {
+                        name: 'notifyEmailAddresses',
+                        type: 'string',
+                        // highlight-start
+                        list: true,
+                        // highlight-end
+                    }
+                ],
+            },
+        ]
+    }
+};
+```
+
 ### Properties for `relation` fields
 
 In addition to the common properties, the `relation` custom fields have some type-specific properties:

+ 99 - 15
docs/docs/reference/admin-ui-api/custom-input-components/default-inputs.md

@@ -58,7 +58,7 @@ class BooleanFormInputComponent implements FormInputComponent {
 
 <GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/html-editor-form-input.component.ts" sourceLine="23" packageName="@vendure/admin-ui" />
 
-A JSON editor input with syntax highlighting and error detection. Works well
+A JSON editor input with syntax highlighting and error detection. Works well
 with `text` type fields.
 
 ```ts title="Signature"
@@ -101,7 +101,7 @@ class HtmlEditorFormInputComponent extends BaseCodeEditorFormInputComponent impl
 
 <GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component.ts" sourceLine="33" packageName="@vendure/admin-ui" />
 
-A JSON editor input with syntax highlighting and error detection. Works well
+A JSON editor input with syntax highlighting and error detection. Works well
 with `text` type fields.
 
 ```ts title="Signature"
@@ -276,7 +276,7 @@ class CurrencyFormInputComponent implements FormInputComponent {
 
 <GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/customer-group-form-input/customer-group-form-input.component.ts" sourceLine="20" packageName="@vendure/admin-ui" />
 
-Allows the selection of a Customer via an autocomplete select input.
+Allows the selection of a Customer via an autocomplete select input.
 Should be used with `ID` type fields which represent Customer IDs.
 
 ```ts title="Signature"
@@ -415,7 +415,7 @@ class DateFormInputComponent implements FormInputComponent {
 
 <GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/facet-value-form-input/facet-value-form-input.component.ts" sourceLine="16" packageName="@vendure/admin-ui" />
 
-Allows the selection of multiple FacetValues via an autocomplete select input.
+Allows the selection of multiple FacetValues via an autocomplete select input.
 Should be used with `ID` type **list** fields which represent FacetValue IDs.
 
 ```ts title="Signature"
@@ -425,13 +425,13 @@ class FacetValueFormInputComponent implements FormInputComponent {
     readonly: boolean;
     formControl: UntypedFormControl;
     config: InputComponentConfig;
-    valueTransformFn = (values: FacetValueFragment[]) => {
-        const isUsedInConfigArg = this.config.__typename === 'ConfigArgDefinition';
-        if (isUsedInConfigArg) {
-            return JSON.stringify(values.map(s => s.id));
-        } else {
-            return values;
-        }
+    valueTransformFn = (values: FacetValueFragment[]) => {
+        const isUsedInConfigArg = this.config.__typename === 'ConfigArgDefinition';
+        if (isUsedInConfigArg) {
+            return JSON.stringify(values.map(s => s.id));
+        } else {
+            return values;
+        }
     };
 }
 ```
@@ -600,7 +600,7 @@ class PasswordFormInputComponent implements FormInputComponent {
 
 <GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/product-selector-form-input/product-selector-form-input.component.ts" sourceLine="20" packageName="@vendure/admin-ui" />
 
-Allows the selection of multiple ProductVariants via an autocomplete select input.
+Allows the selection of multiple ProductVariants via an autocomplete select input.
 Should be used with `ID` type **list** fields which represent ProductVariant IDs.
 
 ```ts title="Signature"
@@ -682,8 +682,8 @@ class ProductSelectorFormInputComponent implements FormInputComponent, OnInit {
 
 <GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/relation-form-input/relation-form-input.component.ts" sourceLine="17" packageName="@vendure/admin-ui" />
 
-The default input component for `relation` type custom fields. Allows the selection
-of a ProductVariant, Product, Customer or Asset. For other entity types, a custom
+The default input component for `relation` type custom fields. Allows the selection
+of a ProductVariant, Product, Customer or Asset. For other entity types, a custom
 implementation will need to be defined. See <a href='/reference/admin-ui-api/custom-input-components/register-form-input-component#registerforminputcomponent'>registerFormInputComponent</a>.
 
 ```ts title="Signature"
@@ -774,7 +774,7 @@ class RichTextFormInputComponent implements FormInputComponent {
 
 <GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/select-form-input/select-form-input.component.ts" sourceLine="18" packageName="@vendure/admin-ui" />
 
-Uses a select input to allow the selection of a string value. Should be used with
+Uses a select input to allow the selection of a string value. Should be used with
 `string` type fields with options.
 
 ```ts title="Signature"
@@ -843,6 +843,90 @@ class SelectFormInputComponent implements FormInputComponent, OnInit {
 
 
 
+</div>
+
+
+## StructFormInputComponent
+
+<GenerationInfo sourceFile="packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/struct-form-input/struct-form-input.component.ts" sourceLine="18" packageName="@vendure/admin-ui" />
+
+A checkbox input. The default input component for `boolean` fields.
+
+```ts title="Signature"
+class StructFormInputComponent implements FormInputComponent, OnInit, OnDestroy {
+    static readonly id: DefaultFormComponentId = 'struct-form-input';
+    readonly: boolean;
+    formControl: UntypedFormControl;
+    config: DefaultFormComponentConfig<'struct-form-input'>;
+    uiLanguage$: Observable<LanguageCode>;
+    protected structFormGroup = new FormGroup({});
+    protected fields: Array<{
+        def: StructCustomFieldFragment['fields'][number];
+        formControl: FormControl;
+    }>;
+    constructor(dataService: DataService)
+    ngOnInit() => ;
+    ngOnDestroy() => ;
+}
+```
+* Implements: <code><a href='/reference/admin-ui-api/custom-input-components/form-input-component#forminputcomponent'>FormInputComponent</a></code>, <code>OnInit</code>, <code>OnDestroy</code>
+
+
+
+<div className="members-wrapper">
+
+### id
+
+<MemberInfo kind="property" type={`<a href='/reference/typescript-api/configurable-operation-def/default-form-component-id#defaultformcomponentid'>DefaultFormComponentId</a>`}   />
+
+
+### readonly
+
+<MemberInfo kind="property" type={`boolean`}   />
+
+
+### formControl
+
+<MemberInfo kind="property" type={`UntypedFormControl`}   />
+
+
+### config
+
+<MemberInfo kind="property" type={`DefaultFormComponentConfig&#60;'struct-form-input'&#62;`}   />
+
+
+### uiLanguage$
+
+<MemberInfo kind="property" type={`Observable&#60;<a href='/reference/typescript-api/common/language-code#languagecode'>LanguageCode</a>&#62;`}   />
+
+
+### structFormGroup
+
+<MemberInfo kind="property" type={``}   />
+
+
+### fields
+
+<MemberInfo kind="property" type={`Array&#60;{
         def: StructCustomFieldFragment['fields'][number];
         formControl: FormControl;
     }&#62;`}   />
+
+
+### constructor
+
+<MemberInfo kind="method" type={`(dataService: <a href='/reference/admin-ui-api/services/data-service#dataservice'>DataService</a>) => StructFormInputComponent`}   />
+
+
+### ngOnInit
+
+<MemberInfo kind="method" type={`() => `}   />
+
+
+### ngOnDestroy
+
+<MemberInfo kind="method" type={`() => `}   />
+
+
+
+
 </div>
 
 

+ 19 - 19
docs/docs/reference/core-plugins/email-plugin/email-plugin-options.md

@@ -19,11 +19,11 @@ Configuration for the EmailPlugin.
 interface EmailPluginOptions {
     templatePath?: string;
     templateLoader?: TemplateLoader;
-    transport:
-        | EmailTransportOptions
-        | ((
-              injector?: Injector,
-              ctx?: RequestContext,
+    transport:
+        | EmailTransportOptions
+        | ((
+              injector?: Injector,
+              ctx?: RequestContext,
           ) => EmailTransportOptions | Promise<EmailTransportOptions>);
     handlers: Array<EmailEventHandler<string, any>>;
     globalTemplateVars?: { [key: string]: any } | GlobalTemplateVarsFn;
@@ -38,44 +38,44 @@ interface EmailPluginOptions {
 
 <MemberInfo kind="property" type={`string`}   />
 
-The path to the location of the email templates. In a default Vendure installation,
+The path to the location of the email templates. In a default Vendure installation,
 the templates are installed to `<project root>/vendure/email/templates`.
 ### templateLoader
 
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/template-loader#templateloader'>TemplateLoader</a>`}  since="2.0.0"  />
 
-An optional TemplateLoader which can be used to load templates from a custom location or async service.
+An optional TemplateLoader which can be used to load templates from a custom location or async service.
 The default uses the FileBasedTemplateLoader which loads templates from `<project root>/vendure/email/templates`
 ### transport
 
-<MemberInfo kind="property" type={`| <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>         | ((               injector?: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>,               ctx?: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>,           ) =&#62; <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a> | Promise&#60;<a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>&#62;)`}   />
+<MemberInfo kind="property" type={`| <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>
         | ((
               injector?: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>,
               ctx?: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>,
           ) =&#62; <a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a> | Promise&#60;<a href='/reference/core-plugins/email-plugin/transport-options#emailtransportoptions'>EmailTransportOptions</a>&#62;)`}   />
 
 Configures how the emails are sent.
 ### handlers
 
 <MemberInfo kind="property" type={`Array&#60;<a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;string, any&#62;&#62;`}   />
 
-An array of <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>s which define which Vendure events will trigger
+An array of <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>s which define which Vendure events will trigger
 emails, and how those emails are generated.
 ### globalTemplateVars
 
 <MemberInfo kind="property" type={`{ [key: string]: any } | <a href='/reference/core-plugins/email-plugin/email-plugin-options#globaltemplatevarsfn'>GlobalTemplateVarsFn</a>`}   />
 
-An object containing variables which are made available to all templates. For example,
-the storefront URL could be defined here and then used in the "email address verification"
-email. Use the GlobalTemplateVarsFn if you need to retrieve variables from Vendure or
+An object containing variables which are made available to all templates. For example,
+the storefront URL could be defined here and then used in the "email address verification"
+email. Use the GlobalTemplateVarsFn if you need to retrieve variables from Vendure or
 plugin services.
 ### emailSender
 
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/email-sender#emailsender'>EmailSender</a>`} default={`<a href='/reference/core-plugins/email-plugin/email-sender#nodemaileremailsender'>NodemailerEmailSender</a>`}   />
 
-An optional allowed EmailSender, used to allow custom implementations of the send functionality
+An optional allowed EmailSender, used to allow custom implementations of the send functionality
 while still utilizing the existing emailPlugin functionality.
 ### emailGenerator
 
 <MemberInfo kind="property" type={`<a href='/reference/core-plugins/email-plugin/email-generator#emailgenerator'>EmailGenerator</a>`} default={`<a href='/reference/core-plugins/email-plugin/email-generator#handlebarsmjmlgenerator'>HandlebarsMjmlGenerator</a>`}   />
 
-An optional allowed EmailGenerator, used to allow custom email generation functionality to
+An optional allowed EmailGenerator, used to allow custom email generation functionality to
 better match with custom email sending functionality.
 
 
@@ -86,8 +86,8 @@ better match with custom email sending functionality.
 
 <GenerationInfo sourceFile="packages/email-plugin/src/types.ts" sourceLine="64" packageName="@vendure/email-plugin" since="2.3.0" />
 
-Allows you to dynamically load the "globalTemplateVars" key async and access Vendure services
-to create the object. This is not a requirement. You can also specify a simple static object if your
+Allows you to dynamically load the "globalTemplateVars" key async and access Vendure services
+to create the object. This is not a requirement. You can also specify a simple static object if your
 projects doesn't need to access async or dynamic values.
 
 *Example*
@@ -112,9 +112,9 @@ EmailPlugin.init({
 ```
 
 ```ts title="Signature"
-type GlobalTemplateVarsFn = (
-    ctx: RequestContext,
-    injector: Injector,
+type GlobalTemplateVarsFn = (
+    ctx: RequestContext,
+    injector: Injector,
 ) => Promise<{ [key: string]: any }>
 ```
 

+ 199 - 1
docs/docs/reference/graphql-api/admin/object-types.md

@@ -267,6 +267,28 @@ import MemberDescription from '@site/src/components/MemberDescription';
 <div class="graphql-code-line ">ui: <a href="/reference/graphql-api/admin/object-types#json">JSON</a></div>
 
 
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## BooleanStructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">BooleanStructFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/admin/object-types#json">JSON</a></div>
+
+
 <div class="graphql-code-line top-level">&#125;</div>
 </div>
 
@@ -793,7 +815,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 <div class="graphql-code-block">
 <div class="graphql-code-line top-level">union <span class="graphql-code-identifier">CustomFieldConfig</span> =</div>
-<div class="graphql-code-line "><a href="/reference/graphql-api/admin/object-types#stringcustomfieldconfig">StringCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#localestringcustomfieldconfig">LocaleStringCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#intcustomfieldconfig">IntCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#floatcustomfieldconfig">FloatCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#booleancustomfieldconfig">BooleanCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#datetimecustomfieldconfig">DateTimeCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#relationcustomfieldconfig">RelationCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#textcustomfieldconfig">TextCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#localetextcustomfieldconfig">LocaleTextCustomFieldConfig</a></div>
+<div class="graphql-code-line "><a href="/reference/graphql-api/admin/object-types#stringcustomfieldconfig">StringCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#localestringcustomfieldconfig">LocaleStringCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#intcustomfieldconfig">IntCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#floatcustomfieldconfig">FloatCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#booleancustomfieldconfig">BooleanCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#datetimecustomfieldconfig">DateTimeCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#relationcustomfieldconfig">RelationCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#textcustomfieldconfig">TextCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#localetextcustomfieldconfig">LocaleTextCustomFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#structcustomfieldconfig">StructCustomFieldConfig</a></div>
 </div>
 
 ## CustomFields
@@ -990,6 +1012,39 @@ import MemberDescription from '@site/src/components/MemberDescription';
 <div class="graphql-code-line ">ui: <a href="/reference/graphql-api/admin/object-types#json">JSON</a></div>
 
 
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## DateTimeStructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level comment">"""</div>
+<div class="graphql-code-line top-level comment">Expects the same validation formats as the <code>&lt;input type="datetime-local"&gt;</code> HTML element.</div>
+
+<div class="graphql-code-line top-level comment">See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Additional_attributes</div>
+<div class="graphql-code-line top-level comment">"""</div>
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">DateTimeStructFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">min: <a href="/reference/graphql-api/admin/object-types#string">String</a></div>
+
+<div class="graphql-code-line ">max: <a href="/reference/graphql-api/admin/object-types#string">String</a></div>
+
+<div class="graphql-code-line ">step: <a href="/reference/graphql-api/admin/object-types#int">Int</a></div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/admin/object-types#json">JSON</a></div>
+
+
 <div class="graphql-code-line top-level">&#125;</div>
 </div>
 
@@ -1310,6 +1365,34 @@ import MemberDescription from '@site/src/components/MemberDescription';
 <div class="graphql-code-line ">ui: <a href="/reference/graphql-api/admin/object-types#json">JSON</a></div>
 
 
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## FloatStructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">FloatStructFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">min: <a href="/reference/graphql-api/admin/object-types#float">Float</a></div>
+
+<div class="graphql-code-line ">max: <a href="/reference/graphql-api/admin/object-types#float">Float</a></div>
+
+<div class="graphql-code-line ">step: <a href="/reference/graphql-api/admin/object-types#float">Float</a></div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/admin/object-types#json">JSON</a></div>
+
+
 <div class="graphql-code-line top-level">&#125;</div>
 </div>
 
@@ -1575,6 +1658,34 @@ import MemberDescription from '@site/src/components/MemberDescription';
 <div class="graphql-code-line ">ui: <a href="/reference/graphql-api/admin/object-types#json">JSON</a></div>
 
 
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## IntStructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">IntStructFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">min: <a href="/reference/graphql-api/admin/object-types#int">Int</a></div>
+
+<div class="graphql-code-line ">max: <a href="/reference/graphql-api/admin/object-types#int">Int</a></div>
+
+<div class="graphql-code-line ">step: <a href="/reference/graphql-api/admin/object-types#int">Int</a></div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/admin/object-types#json">JSON</a></div>
+
+
 <div class="graphql-code-line top-level">&#125;</div>
 </div>
 
@@ -3849,6 +3960,71 @@ import MemberDescription from '@site/src/components/MemberDescription';
 <div class="graphql-code-line top-level">&#125;</div>
 </div>
 
+## StringStructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">StringStructFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">length: <a href="/reference/graphql-api/admin/object-types#int">Int</a></div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">pattern: <a href="/reference/graphql-api/admin/object-types#string">String</a></div>
+
+<div class="graphql-code-line ">options: [<a href="/reference/graphql-api/admin/object-types#stringfieldoption">StringFieldOption</a>!]</div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/admin/object-types#json">JSON</a></div>
+
+
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## StructCustomFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">StructCustomFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">fields: [<a href="/reference/graphql-api/admin/object-types#structfieldconfig">StructFieldConfig</a>!]!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">readonly: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">internal: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">requiresPermission: [<a href="/reference/graphql-api/admin/enums#permission">Permission</a>!]</div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/admin/object-types#json">JSON</a></div>
+
+
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## StructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">union <span class="graphql-code-identifier">StructFieldConfig</span> =</div>
+<div class="graphql-code-line "><a href="/reference/graphql-api/admin/object-types#stringstructfieldconfig">StringStructFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#intstructfieldconfig">IntStructFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#floatstructfieldconfig">FloatStructFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#booleanstructfieldconfig">BooleanStructFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#datetimestructfieldconfig">DateTimeStructFieldConfig</a> | <a href="/reference/graphql-api/admin/object-types#textstructfieldconfig">TextStructFieldConfig</a></div>
+</div>
+
 ## Success
 
 <div class="graphql-code-block">
@@ -4051,6 +4227,28 @@ import MemberDescription from '@site/src/components/MemberDescription';
 <div class="graphql-code-line ">ui: <a href="/reference/graphql-api/admin/object-types#json">JSON</a></div>
 
 
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## TextStructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">TextStructFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/admin/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/admin/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/admin/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/admin/object-types#json">JSON</a></div>
+
+
 <div class="graphql-code-line top-level">&#125;</div>
 </div>
 

+ 199 - 1
docs/docs/reference/graphql-api/shop/object-types.md

@@ -207,6 +207,28 @@ import MemberDescription from '@site/src/components/MemberDescription';
 <div class="graphql-code-line ">ui: <a href="/reference/graphql-api/shop/object-types#json">JSON</a></div>
 
 
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## BooleanStructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">BooleanStructFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/shop/object-types#json">JSON</a></div>
+
+
 <div class="graphql-code-line top-level">&#125;</div>
 </div>
 
@@ -581,7 +603,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 <div class="graphql-code-block">
 <div class="graphql-code-line top-level">union <span class="graphql-code-identifier">CustomFieldConfig</span> =</div>
-<div class="graphql-code-line "><a href="/reference/graphql-api/shop/object-types#stringcustomfieldconfig">StringCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#localestringcustomfieldconfig">LocaleStringCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#intcustomfieldconfig">IntCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#floatcustomfieldconfig">FloatCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#booleancustomfieldconfig">BooleanCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#datetimecustomfieldconfig">DateTimeCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#relationcustomfieldconfig">RelationCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#textcustomfieldconfig">TextCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#localetextcustomfieldconfig">LocaleTextCustomFieldConfig</a></div>
+<div class="graphql-code-line "><a href="/reference/graphql-api/shop/object-types#stringcustomfieldconfig">StringCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#localestringcustomfieldconfig">LocaleStringCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#intcustomfieldconfig">IntCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#floatcustomfieldconfig">FloatCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#booleancustomfieldconfig">BooleanCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#datetimecustomfieldconfig">DateTimeCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#relationcustomfieldconfig">RelationCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#textcustomfieldconfig">TextCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#localetextcustomfieldconfig">LocaleTextCustomFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#structcustomfieldconfig">StructCustomFieldConfig</a></div>
 </div>
 
 ## Customer
@@ -693,6 +715,39 @@ import MemberDescription from '@site/src/components/MemberDescription';
 <div class="graphql-code-line ">ui: <a href="/reference/graphql-api/shop/object-types#json">JSON</a></div>
 
 
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## DateTimeStructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level comment">"""</div>
+<div class="graphql-code-line top-level comment">Expects the same validation formats as the <code>&lt;input type="datetime-local"&gt;</code> HTML element.</div>
+
+<div class="graphql-code-line top-level comment">See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Additional_attributes</div>
+<div class="graphql-code-line top-level comment">"""</div>
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">DateTimeStructFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">min: <a href="/reference/graphql-api/shop/object-types#string">String</a></div>
+
+<div class="graphql-code-line ">max: <a href="/reference/graphql-api/shop/object-types#string">String</a></div>
+
+<div class="graphql-code-line ">step: <a href="/reference/graphql-api/shop/object-types#int">Int</a></div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/shop/object-types#json">JSON</a></div>
+
+
 <div class="graphql-code-line top-level">&#125;</div>
 </div>
 
@@ -917,6 +972,34 @@ import MemberDescription from '@site/src/components/MemberDescription';
 <div class="graphql-code-line ">ui: <a href="/reference/graphql-api/shop/object-types#json">JSON</a></div>
 
 
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## FloatStructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">FloatStructFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">min: <a href="/reference/graphql-api/shop/object-types#float">Float</a></div>
+
+<div class="graphql-code-line ">max: <a href="/reference/graphql-api/shop/object-types#float">Float</a></div>
+
+<div class="graphql-code-line ">step: <a href="/reference/graphql-api/shop/object-types#float">Float</a></div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/shop/object-types#json">JSON</a></div>
+
+
 <div class="graphql-code-line top-level">&#125;</div>
 </div>
 
@@ -1145,6 +1228,34 @@ import MemberDescription from '@site/src/components/MemberDescription';
 <div class="graphql-code-line ">ui: <a href="/reference/graphql-api/shop/object-types#json">JSON</a></div>
 
 
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## IntStructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">IntStructFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">min: <a href="/reference/graphql-api/shop/object-types#int">Int</a></div>
+
+<div class="graphql-code-line ">max: <a href="/reference/graphql-api/shop/object-types#int">Int</a></div>
+
+<div class="graphql-code-line ">step: <a href="/reference/graphql-api/shop/object-types#int">Int</a></div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/shop/object-types#json">JSON</a></div>
+
+
 <div class="graphql-code-line top-level">&#125;</div>
 </div>
 
@@ -2800,6 +2911,71 @@ import MemberDescription from '@site/src/components/MemberDescription';
 <div class="graphql-code-line top-level">&#125;</div>
 </div>
 
+## StringStructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">StringStructFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">length: <a href="/reference/graphql-api/shop/object-types#int">Int</a></div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">pattern: <a href="/reference/graphql-api/shop/object-types#string">String</a></div>
+
+<div class="graphql-code-line ">options: [<a href="/reference/graphql-api/shop/object-types#stringfieldoption">StringFieldOption</a>!]</div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/shop/object-types#json">JSON</a></div>
+
+
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## StructCustomFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">StructCustomFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">fields: [<a href="/reference/graphql-api/shop/object-types#structfieldconfig">StructFieldConfig</a>!]!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">readonly: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">internal: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">requiresPermission: [<a href="/reference/graphql-api/shop/enums#permission">Permission</a>!]</div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/shop/object-types#json">JSON</a></div>
+
+
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## StructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">union <span class="graphql-code-identifier">StructFieldConfig</span> =</div>
+<div class="graphql-code-line "><a href="/reference/graphql-api/shop/object-types#stringstructfieldconfig">StringStructFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#intstructfieldconfig">IntStructFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#floatstructfieldconfig">FloatStructFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#booleanstructfieldconfig">BooleanStructFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#datetimestructfieldconfig">DateTimeStructFieldConfig</a> | <a href="/reference/graphql-api/shop/object-types#textstructfieldconfig">TextStructFieldConfig</a></div>
+</div>
+
 ## Success
 
 <div class="graphql-code-block">
@@ -2964,6 +3140,28 @@ import MemberDescription from '@site/src/components/MemberDescription';
 <div class="graphql-code-line ">ui: <a href="/reference/graphql-api/shop/object-types#json">JSON</a></div>
 
 
+<div class="graphql-code-line top-level">&#125;</div>
+</div>
+
+## TextStructFieldConfig
+
+<div class="graphql-code-block">
+<div class="graphql-code-line top-level">type <span class="graphql-code-identifier">TextStructFieldConfig</span> &#123;</div>
+<div class="graphql-code-line ">name: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">type: <a href="/reference/graphql-api/shop/object-types#string">String</a>!</div>
+
+<div class="graphql-code-line ">list: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a>!</div>
+
+<div class="graphql-code-line ">label: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">description: [<a href="/reference/graphql-api/shop/object-types#localizedstring">LocalizedString</a>!]</div>
+
+<div class="graphql-code-line ">nullable: <a href="/reference/graphql-api/shop/object-types#boolean">Boolean</a></div>
+
+<div class="graphql-code-line ">ui: <a href="/reference/graphql-api/shop/object-types#json">JSON</a></div>
+
+
 <div class="graphql-code-line top-level">&#125;</div>
 </div>
 

+ 6 - 6
docs/docs/reference/typescript-api/auth/default-session-cache-strategy.md

@@ -13,16 +13,16 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 <GenerationInfo sourceFile="packages/core/src/config/session-cache/default-session-cache-strategy.ts" sourceLine="17" packageName="@vendure/core" since="3.1.0" />
 
-The default <a href='/reference/typescript-api/auth/session-cache-strategy#sessioncachestrategy'>SessionCacheStrategy</a> delegates to the configured
-<a href='/reference/typescript-api/cache/cache-strategy#cachestrategy'>CacheStrategy</a> to store the session data. This should be suitable
+The default <a href='/reference/typescript-api/auth/session-cache-strategy#sessioncachestrategy'>SessionCacheStrategy</a> delegates to the configured
+<a href='/reference/typescript-api/cache/cache-strategy#cachestrategy'>CacheStrategy</a> to store the session data. This should be suitable
 for most use-cases, assuming you select a suitable <a href='/reference/typescript-api/cache/cache-strategy#cachestrategy'>CacheStrategy</a>
 
 ```ts title="Signature"
 class DefaultSessionCacheStrategy implements SessionCacheStrategy {
     protected cacheService: CacheService;
-    constructor(options?: {
-            ttl?: number;
-            cachePrefix?: string;
+    constructor(options?: {
+            ttl?: number;
+            cachePrefix?: string;
         })
     init(injector: Injector) => ;
     set(session: CachedSession) => Promise<void>;
@@ -44,7 +44,7 @@ class DefaultSessionCacheStrategy implements SessionCacheStrategy {
 
 ### constructor
 
-<MemberInfo kind="method" type={`(options?: {             ttl?: number;             cachePrefix?: string;         }) => DefaultSessionCacheStrategy`}   />
+<MemberInfo kind="method" type={`(options?: {
             ttl?: number;
             cachePrefix?: string;
         }) => DefaultSessionCacheStrategy`}   />
 
 
 ### init

+ 36 - 36
docs/docs/reference/typescript-api/auth/external-authentication-service.md

@@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 <GenerationInfo sourceFile="packages/core/src/service/helpers/external-authentication/external-authentication.service.ts" sourceLine="24" packageName="@vendure/core" />
 
-This is a helper service which exposes methods related to looking up and creating Users based on an
+This is a helper service which exposes methods related to looking up and creating Users based on an
 external <a href='/reference/typescript-api/auth/authentication-strategy#authenticationstrategy'>AuthenticationStrategy</a>.
 
 ```ts title="Signature"
@@ -21,27 +21,27 @@ class ExternalAuthenticationService {
     constructor(connection: TransactionalConnection, roleService: RoleService, historyService: HistoryService, customerService: CustomerService, administratorService: AdministratorService, channelService: ChannelService)
     findCustomerUser(ctx: RequestContext, strategy: string, externalIdentifier: string, checkCurrentChannelOnly:  = true) => Promise<User | undefined>;
     findAdministratorUser(ctx: RequestContext, strategy: string, externalIdentifier: string) => Promise<User | undefined>;
-    createCustomerAndUser(ctx: RequestContext, config: {
-            strategy: string;
-            externalIdentifier: string;
-            emailAddress: string;
-            firstName: string;
-            lastName: string;
-            verified?: boolean;
+    createCustomerAndUser(ctx: RequestContext, config: {
+            strategy: string;
+            externalIdentifier: string;
+            emailAddress: string;
+            firstName: string;
+            lastName: string;
+            verified?: boolean;
         }) => Promise<User>;
-    createAdministratorAndUser(ctx: RequestContext, config: {
-            strategy: string;
-            externalIdentifier: string;
-            identifier: string;
-            emailAddress?: string;
-            firstName?: string;
-            lastName?: string;
-            roles: Role[];
+    createAdministratorAndUser(ctx: RequestContext, config: {
+            strategy: string;
+            externalIdentifier: string;
+            identifier: string;
+            emailAddress?: string;
+            firstName?: string;
+            lastName?: string;
+            roles: Role[];
         }) => ;
     findUser(ctx: RequestContext, strategy: string, externalIdentifier: string) => Promise<User | undefined>;
-    createUser(ctx: RequestContext, config: {
-            strategy: string;
-            externalIdentifier: string;
+    createUser(ctx: RequestContext, config: {
+            strategy: string;
+            externalIdentifier: string;
         }) => Promise<User>;
 }
 ```
@@ -57,32 +57,32 @@ class ExternalAuthenticationService {
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, strategy: string, externalIdentifier: string, checkCurrentChannelOnly:  = true) => Promise&#60;<a href='/reference/typescript-api/entities/user#user'>User</a> | undefined&#62;`}   />
 
-Looks up a User based on their identifier from an external authentication
-provider, ensuring this User is associated with a Customer account.
-
-By default, only customers in the currently-active Channel will be checked.
-By passing `false` as the `checkCurrentChannelOnly` argument, _all_ channels
+Looks up a User based on their identifier from an external authentication
+provider, ensuring this User is associated with a Customer account.
+
+By default, only customers in the currently-active Channel will be checked.
+By passing `false` as the `checkCurrentChannelOnly` argument, _all_ channels
 will be checked.
 ### findAdministratorUser
 
 <MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, strategy: string, externalIdentifier: string) => Promise&#60;<a href='/reference/typescript-api/entities/user#user'>User</a> | undefined&#62;`}   />
 
-Looks up a User based on their identifier from an external authentication
+Looks up a User based on their identifier from an external authentication
 provider, ensuring this User is associated with an Administrator account.
 ### createCustomerAndUser
 
-<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, config: {             strategy: string;             externalIdentifier: string;             emailAddress: string;             firstName: string;             lastName: string;             verified?: boolean;         }) => Promise&#60;<a href='/reference/typescript-api/entities/user#user'>User</a>&#62;`}   />
+<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, config: {
             strategy: string;
             externalIdentifier: string;
             emailAddress: string;
             firstName: string;
             lastName: string;
             verified?: boolean;
         }) => Promise&#60;<a href='/reference/typescript-api/entities/user#user'>User</a>&#62;`}   />
 
-If a customer has been successfully authenticated by an external authentication provider, yet cannot
-be found using `findCustomerUser`, then we need to create a new User and
-Customer record in Vendure for that user. This method encapsulates that logic as well as additional
+If a customer has been successfully authenticated by an external authentication provider, yet cannot
+be found using `findCustomerUser`, then we need to create a new User and
+Customer record in Vendure for that user. This method encapsulates that logic as well as additional
 housekeeping such as adding a record to the Customer's history.
 ### createAdministratorAndUser
 
-<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, config: {             strategy: string;             externalIdentifier: string;             identifier: string;             emailAddress?: string;             firstName?: string;             lastName?: string;             roles: <a href='/reference/typescript-api/entities/role#role'>Role</a>[];         }) => `}   />
+<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, config: {
             strategy: string;
             externalIdentifier: string;
             identifier: string;
             emailAddress?: string;
             firstName?: string;
             lastName?: string;
             roles: <a href='/reference/typescript-api/entities/role#role'>Role</a>[];
         }) => `}   />
 
-If an administrator has been successfully authenticated by an external authentication provider, yet cannot
-be found using `findAdministratorUser`, then we need to create a new User and
+If an administrator has been successfully authenticated by an external authentication provider, yet cannot
+be found using `findAdministratorUser`, then we need to create a new User and
 Administrator record in Vendure for that user.
 ### findUser
 
@@ -91,11 +91,11 @@ Administrator record in Vendure for that user.
 
 ### createUser
 
-<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, config: {             strategy: string;             externalIdentifier: string;         }) => Promise&#60;<a href='/reference/typescript-api/entities/user#user'>User</a>&#62;`}   />
+<MemberInfo kind="method" type={`(ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, config: {
             strategy: string;
             externalIdentifier: string;
         }) => Promise&#60;<a href='/reference/typescript-api/entities/user#user'>User</a>&#62;`}   />
 
-Looks up a User based on their identifier from an external authentication
-provider. Creates the user if does not exist. Unlike `findCustomerUser` and `findAdministratorUser`,
-this method does not enforce that the User is associated with a Customer or
+Looks up a User based on their identifier from an external authentication
+provider. Creates the user if does not exist. Unlike `findCustomerUser` and `findAdministratorUser`,
+this method does not enforce that the User is associated with a Customer or
 Administrator account.
 
 

+ 1 - 1
docs/docs/reference/typescript-api/common/admin-ui/admin-ui-app-config.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## AdminUiAppConfig
 
-<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="349" packageName="@vendure/common" />
+<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="355" packageName="@vendure/common" />
 
 Configures the path to a custom-build of the Admin UI app.
 

+ 1 - 1
docs/docs/reference/typescript-api/common/admin-ui/admin-ui-app-dev-mode-config.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## AdminUiAppDevModeConfig
 
-<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="377" packageName="@vendure/common" />
+<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="383" packageName="@vendure/common" />
 
 Information about the Admin UI app dev server.
 

+ 1 - 1
docs/docs/reference/typescript-api/common/admin-ui/admin-ui-config.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## AdminUiConfig
 
-<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="215" packageName="@vendure/common" />
+<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="221" packageName="@vendure/common" />
 
 This interface describes JSON config file (vendure-ui-config.json) used by the Admin UI.
 The values are loaded at run-time by the Admin UI app, and allow core configuration to be

+ 1 - 1
docs/docs/reference/typescript-api/common/currency-code.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## CurrencyCode
 
-<GenerationInfo sourceFile="packages/common/src/generated-types.ts" sourceLine="982" packageName="@vendure/common" />
+<GenerationInfo sourceFile="packages/common/src/generated-types.ts" sourceLine="993" packageName="@vendure/common" />
 
 ISO 4217 currency code
 

+ 1 - 1
docs/docs/reference/typescript-api/common/job-state.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## JobState
 
-<GenerationInfo sourceFile="packages/common/src/generated-types.ts" sourceLine="2174" packageName="@vendure/common" />
+<GenerationInfo sourceFile="packages/common/src/generated-types.ts" sourceLine="2231" packageName="@vendure/common" />
 
 The state of a Job in the JobQueue
 

+ 1 - 1
docs/docs/reference/typescript-api/common/language-code.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## LanguageCode
 
-<GenerationInfo sourceFile="packages/common/src/generated-types.ts" sourceLine="2192" packageName="@vendure/common" />
+<GenerationInfo sourceFile="packages/common/src/generated-types.ts" sourceLine="2249" packageName="@vendure/common" />
 
 Languages in the form of a ISO 639-1 language code with optional
 region or script modifier (e.g. de_AT). The selection available is based

+ 1 - 1
docs/docs/reference/typescript-api/common/permission.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## Permission
 
-<GenerationInfo sourceFile="packages/common/src/generated-types.ts" sourceLine="4335" packageName="@vendure/common" />
+<GenerationInfo sourceFile="packages/common/src/generated-types.ts" sourceLine="4392" packageName="@vendure/common" />
 
 Permissions for administrators and customers. Used to control access to
 GraphQL resolvers via the <a href='/reference/typescript-api/request/allow-decorator#allow'>Allow</a> decorator.

+ 1 - 1
docs/docs/reference/typescript-api/configurable-operation-def/config-arg-type.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## ConfigArgType
 
-<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="126" packageName="@vendure/common" />
+<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="130" packageName="@vendure/common" />
 
 Certain entities (those which implement <a href='/reference/typescript-api/configurable-operation-def/#configurableoperationdef'>ConfigurableOperationDef</a>) allow arbitrary
 configuration arguments to be specified which can then be set in the admin-ui and used in

+ 2 - 1
docs/docs/reference/typescript-api/configurable-operation-def/default-form-component-id.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## DefaultFormComponentId
 
-<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="135" packageName="@vendure/common" />
+<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="139" packageName="@vendure/common" />
 
 The ids of the default form input components that ship with the
 Admin UI.
@@ -34,4 +34,5 @@ type DefaultFormComponentId = | 'boolean-form-input'
     | 'textarea-form-input'
     | 'product-multi-form-input'
     | 'combination-mode-form-input'
+    | 'struct-form-input'
 ```

+ 7 - 1
docs/docs/reference/typescript-api/configurable-operation-def/default-form-config-hash.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## DefaultFormConfigHash
 
-<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="160" packageName="@vendure/common" />
+<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="165" packageName="@vendure/common" />
 
 Used to define the expected arguments for a given default form input component.
 
@@ -40,6 +40,7 @@ type DefaultFormConfigHash = {
         selectionMode?: 'product' | 'variant';
     };
     'combination-mode-form-input': Record<string, never>;
+    'struct-form-input': Record<string, never>;
 }
 ```
 
@@ -130,6 +131,11 @@ type DefaultFormConfigHash = {
 <MemberInfo kind="property" type={`Record&#60;string, never&#62;`}   />
 
 
+### 'struct-form-input'
+
+<MemberInfo kind="property" type={`Record&#60;string, never&#62;`}   />
+
+
 
 
 </div>

+ 2 - 1
docs/docs/reference/typescript-api/custom-fields/custom-field-config.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## CustomFieldConfig
 
-<GenerationInfo sourceFile="packages/core/src/config/custom-field/custom-field-types.ts" sourceLine="124" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/custom-field/custom-field-types.ts" sourceLine="228" packageName="@vendure/core" />
 
 An object used to configure a custom field.
 
@@ -25,4 +25,5 @@ type CustomFieldConfig = | StringCustomFieldConfig
     | BooleanCustomFieldConfig
     | DateTimeCustomFieldConfig
     | RelationCustomFieldConfig
+    | StructCustomFieldConfig
 ```

+ 4 - 2
docs/docs/reference/typescript-api/custom-fields/custom-field-type.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## CustomFieldType
 
-<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="103" packageName="@vendure/common" />
+<GenerationInfo sourceFile="packages/common/src/shared-types.ts" sourceLine="104" packageName="@vendure/common" />
 
 A data type for a custom field. The CustomFieldType determines the data types used in the generated
 database columns and GraphQL fields as follows (key: m = MySQL, p = Postgres, s = SQLite):
@@ -21,11 +21,12 @@ Type         | DB type                               | GraphQL type
 string       | varchar                               | String
 localeString | varchar                               | String
 text         | longtext(m), text(p,s)                | String
-localeText    | longtext(m), text(p,s)                | String
+localeText   | longtext(m), text(p,s)                | String
 int          | int                                   | Int
 float        | double precision                      | Float
 boolean      | tinyint (m), bool (p), boolean (s)    | Boolean
 datetime     | datetime (m,s), timestamp (p)         | DateTime
+struct       | json (m), jsonb (p), text (s)         | JSON
 relation     | many-to-one / many-to-many relation   | As specified in config
 
 Additionally, the CustomFieldType also dictates which [configuration options](/reference/typescript-api/custom-fields/#custom-field-config-properties)
@@ -41,4 +42,5 @@ type CustomFieldType = | 'string'
     | 'relation'
     | 'text'
     | 'localeText'
+    | 'struct'
 ```

+ 1 - 1
docs/docs/reference/typescript-api/custom-fields/index.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## CustomFields
 
-<GenerationInfo sourceFile="packages/core/src/config/custom-field/custom-field-types.ts" sourceLine="159" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/custom-field/custom-field-types.ts" sourceLine="264" packageName="@vendure/core" />
 
 Most entities can have additional fields added to them by defining an array of <a href='/reference/typescript-api/custom-fields/custom-field-config#customfieldconfig'>CustomFieldConfig</a>objects on against the corresponding key.
 

+ 25 - 0
docs/docs/reference/typescript-api/custom-fields/struct-custom-field-config.md

@@ -0,0 +1,25 @@
+---
+title: "StructCustomFieldConfig"
+isDefaultIndex: false
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+import MemberInfo from '@site/src/components/MemberInfo';
+import GenerationInfo from '@site/src/components/GenerationInfo';
+import MemberDescription from '@site/src/components/MemberDescription';
+
+
+## StructCustomFieldConfig
+
+<GenerationInfo sourceFile="packages/core/src/config/custom-field/custom-field-types.ts" sourceLine="215" packageName="@vendure/core" since="3.1.0" />
+
+Configures a "struct" custom field.
+
+```ts title="Signature"
+type StructCustomFieldConfig = TypedCustomFieldConfig<
+    'struct',
+    Omit<GraphQLStructCustomFieldConfig, 'fields'>
+> & {
+    fields: StructFieldConfig[];
+}
+```

+ 45 - 0
docs/docs/reference/typescript-api/custom-fields/struct-field-config.md

@@ -0,0 +1,45 @@
+---
+title: "StructFieldConfig"
+isDefaultIndex: false
+generated: true
+---
+<!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+import MemberInfo from '@site/src/components/MemberInfo';
+import GenerationInfo from '@site/src/components/GenerationInfo';
+import MemberDescription from '@site/src/components/MemberDescription';
+
+
+## StructFieldConfig
+
+<GenerationInfo sourceFile="packages/core/src/config/custom-field/custom-field-types.ts" sourceLine="200" packageName="@vendure/core" since="3.1.0" />
+
+Configures an individual field of a "struct" custom field. The individual fields share
+the same API as the top-level custom fields, with the exception that they do not support the
+`readonly`, `internal`, `nullable`, `unique` and `requiresPermission` options.
+
+*Example*
+
+```ts
+const customFields: CustomFields = {
+  Product: [
+    {
+      name: 'specifications',
+      type: 'struct',
+      fields: [
+        { name: 'processor', type: 'string' },
+        { name: 'ram', type: 'string' },
+        { name: 'screenSize', type: 'float' },
+      ],
+    },
+  ],
+};
+```
+
+```ts title="Signature"
+type StructFieldConfig = | StringStructFieldConfig
+    | TextStructFieldConfig
+    | IntStructFieldConfig
+    | FloatStructFieldConfig
+    | BooleanStructFieldConfig
+    | DateTimeStructFieldConfig
+```

+ 1 - 1
docs/docs/reference/typescript-api/custom-fields/typed-custom-single-field-config.md

@@ -11,7 +11,7 @@ import MemberDescription from '@site/src/components/MemberDescription';
 
 ## TypedCustomSingleFieldConfig
 
-<GenerationInfo sourceFile="packages/core/src/config/custom-field/custom-field-types.ts" sourceLine="66" packageName="@vendure/core" />
+<GenerationInfo sourceFile="packages/core/src/config/custom-field/custom-field-types.ts" sourceLine="75" packageName="@vendure/core" />
 
 Configures a custom field on an entity in the <a href='/reference/typescript-api/custom-fields/#customfields'>CustomFields</a> config object.
 

+ 10 - 10
docs/docs/reference/typescript-api/testing/simple-graph-qlclient.md

@@ -27,10 +27,10 @@ class SimpleGraphQLClient {
     asUserWithCredentials(username: string, password: string) => ;
     asSuperAdmin() => ;
     asAnonymousUser() => ;
-    fileUploadMutation(options: {
-        mutation: DocumentNode;
-        filePaths: string[];
-        mapVariables: (filePaths: string[]) => any;
+    fileUploadMutation(options: {
+        mutation: DocumentNode;
+        filePaths: string[];
+        mapVariables: (filePaths: string[]) => any;
     }) => Promise<any>;
 }
 ```
@@ -66,8 +66,8 @@ Performs both query and mutation operations.
 
 <MemberInfo kind="method" type={`(url: string, options: RequestInit = {}) => Promise&#60;Response&#62;`}   />
 
-Performs a raw HTTP request to the given URL, but also includes the authToken & channelToken
-headers if they have been set. Useful for testing non-GraphQL endpoints, e.g. for plugins
+Performs a raw HTTP request to the given URL, but also includes the authToken & channelToken
+headers if they have been set. Useful for testing non-GraphQL endpoints, e.g. for plugins
 which make use of REST controllers.
 ### queryStatus
 
@@ -91,11 +91,11 @@ Logs in as the SuperAdmin user.
 Logs out so that the client is then treated as an anonymous user.
 ### fileUploadMutation
 
-<MemberInfo kind="method" type={`(options: {         mutation: DocumentNode;         filePaths: string[];         mapVariables: (filePaths: string[]) =&#62; any;     }) => Promise&#60;any&#62;`}   />
+<MemberInfo kind="method" type={`(options: {
         mutation: DocumentNode;
         filePaths: string[];
         mapVariables: (filePaths: string[]) =&#62; any;
     }) => Promise&#60;any&#62;`}   />
 
-Perform a file upload mutation.
-
-Upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec
+Perform a file upload mutation.
+
+Upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec
 Discussion of issue: https://github.com/jaydenseric/apollo-upload-client/issues/32
 
 

+ 2 - 0
graphql.config.yml

@@ -0,0 +1,2 @@
+schema: ./schema-admin.json
+documents: '**/*.graphql'

文件差異過大導致無法顯示
+ 113 - 2
packages/admin-ui/src/lib/core/src/common/generated-types.ts


+ 260 - 328
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -1,331 +1,263 @@
 /* eslint-disable */
 
-      export interface PossibleTypesResultData {
-        possibleTypes: {
-          [key: string]: string[]
-        }
-      }
-      const result: PossibleTypesResultData = {
-  "possibleTypes": {
-    "AddFulfillmentToOrderResult": [
-      "CreateFulfillmentError",
-      "EmptyOrderLineSelectionError",
-      "Fulfillment",
-      "FulfillmentStateTransitionError",
-      "InsufficientStockOnHandError",
-      "InvalidFulfillmentHandlerError",
-      "ItemsAlreadyFulfilledError"
-    ],
-    "AddManualPaymentToOrderResult": [
-      "ManualPaymentStateError",
-      "Order"
-    ],
-    "ApplyCouponCodeResult": [
-      "CouponCodeExpiredError",
-      "CouponCodeInvalidError",
-      "CouponCodeLimitError",
-      "Order"
-    ],
-    "AuthenticationResult": [
-      "CurrentUser",
-      "InvalidCredentialsError"
-    ],
-    "CancelOrderResult": [
-      "CancelActiveOrderError",
-      "EmptyOrderLineSelectionError",
-      "MultipleOrderError",
-      "Order",
-      "OrderStateTransitionError",
-      "QuantityTooGreatError"
-    ],
-    "CancelPaymentResult": [
-      "CancelPaymentError",
-      "Payment",
-      "PaymentStateTransitionError"
-    ],
-    "CreateAssetResult": [
-      "Asset",
-      "MimeTypeError"
-    ],
-    "CreateChannelResult": [
-      "Channel",
-      "LanguageNotAvailableError"
-    ],
-    "CreateCustomerResult": [
-      "Customer",
-      "EmailAddressConflictError"
-    ],
-    "CreatePromotionResult": [
-      "MissingConditionsError",
-      "Promotion"
-    ],
-    "CustomField": [
-      "BooleanCustomFieldConfig",
-      "DateTimeCustomFieldConfig",
-      "FloatCustomFieldConfig",
-      "IntCustomFieldConfig",
-      "LocaleStringCustomFieldConfig",
-      "LocaleTextCustomFieldConfig",
-      "RelationCustomFieldConfig",
-      "StringCustomFieldConfig",
-      "TextCustomFieldConfig"
-    ],
-    "CustomFieldConfig": [
-      "BooleanCustomFieldConfig",
-      "DateTimeCustomFieldConfig",
-      "FloatCustomFieldConfig",
-      "IntCustomFieldConfig",
-      "LocaleStringCustomFieldConfig",
-      "LocaleTextCustomFieldConfig",
-      "RelationCustomFieldConfig",
-      "StringCustomFieldConfig",
-      "TextCustomFieldConfig"
-    ],
-    "DuplicateEntityResult": [
-      "DuplicateEntityError",
-      "DuplicateEntitySuccess"
-    ],
-    "ErrorResult": [
-      "AlreadyRefundedError",
-      "CancelActiveOrderError",
-      "CancelPaymentError",
-      "ChannelDefaultLanguageError",
-      "CouponCodeExpiredError",
-      "CouponCodeInvalidError",
-      "CouponCodeLimitError",
-      "CreateFulfillmentError",
-      "DuplicateEntityError",
-      "EmailAddressConflictError",
-      "EmptyOrderLineSelectionError",
-      "FacetInUseError",
-      "FulfillmentStateTransitionError",
-      "GuestCheckoutError",
-      "IneligibleShippingMethodError",
-      "InsufficientStockError",
-      "InsufficientStockOnHandError",
-      "InvalidCredentialsError",
-      "InvalidFulfillmentHandlerError",
-      "ItemsAlreadyFulfilledError",
-      "LanguageNotAvailableError",
-      "ManualPaymentStateError",
-      "MimeTypeError",
-      "MissingConditionsError",
-      "MultipleOrderError",
-      "NativeAuthStrategyError",
-      "NegativeQuantityError",
-      "NoActiveOrderError",
-      "NoChangesSpecifiedError",
-      "NothingToRefundError",
-      "OrderLimitError",
-      "OrderModificationError",
-      "OrderModificationStateError",
-      "OrderStateTransitionError",
-      "PaymentMethodMissingError",
-      "PaymentOrderMismatchError",
-      "PaymentStateTransitionError",
-      "ProductOptionInUseError",
-      "QuantityTooGreatError",
-      "RefundAmountError",
-      "RefundOrderStateError",
-      "RefundPaymentIdMissingError",
-      "RefundStateTransitionError",
-      "SettlePaymentError"
-    ],
-    "ModifyOrderResult": [
-      "CouponCodeExpiredError",
-      "CouponCodeInvalidError",
-      "CouponCodeLimitError",
-      "IneligibleShippingMethodError",
-      "InsufficientStockError",
-      "NegativeQuantityError",
-      "NoChangesSpecifiedError",
-      "Order",
-      "OrderLimitError",
-      "OrderModificationStateError",
-      "PaymentMethodMissingError",
-      "RefundPaymentIdMissingError"
-    ],
-    "NativeAuthenticationResult": [
-      "CurrentUser",
-      "InvalidCredentialsError",
-      "NativeAuthStrategyError"
-    ],
-    "Node": [
-      "Address",
-      "Administrator",
-      "Allocation",
-      "Asset",
-      "AuthenticationMethod",
-      "Cancellation",
-      "Channel",
-      "Collection",
-      "Country",
-      "Customer",
-      "CustomerGroup",
-      "Facet",
-      "FacetValue",
-      "Fulfillment",
-      "HistoryEntry",
-      "Job",
-      "Order",
-      "OrderLine",
-      "OrderModification",
-      "Payment",
-      "PaymentMethod",
-      "Product",
-      "ProductOption",
-      "ProductOptionGroup",
-      "ProductVariant",
-      "Promotion",
-      "Province",
-      "Refund",
-      "Release",
-      "Return",
-      "Role",
-      "Sale",
-      "Seller",
-      "ShippingMethod",
-      "StockAdjustment",
-      "StockLevel",
-      "StockLocation",
-      "Surcharge",
-      "Tag",
-      "TaxCategory",
-      "TaxRate",
-      "User",
-      "Zone"
-    ],
-    "PaginatedList": [
-      "AdministratorList",
-      "AssetList",
-      "ChannelList",
-      "CollectionList",
-      "CountryList",
-      "CustomerGroupList",
-      "CustomerList",
-      "FacetList",
-      "FacetValueList",
-      "HistoryEntryList",
-      "JobList",
-      "OrderList",
-      "PaymentMethodList",
-      "ProductList",
-      "ProductVariantList",
-      "PromotionList",
-      "ProvinceList",
-      "RoleList",
-      "SellerList",
-      "ShippingMethodList",
-      "StockLocationList",
-      "TagList",
-      "TaxCategoryList",
-      "TaxRateList",
-      "ZoneList"
-    ],
-    "RefundOrderResult": [
-      "AlreadyRefundedError",
-      "MultipleOrderError",
-      "NothingToRefundError",
-      "OrderStateTransitionError",
-      "PaymentOrderMismatchError",
-      "QuantityTooGreatError",
-      "Refund",
-      "RefundAmountError",
-      "RefundOrderStateError",
-      "RefundStateTransitionError"
-    ],
-    "Region": [
-      "Country",
-      "Province"
-    ],
-    "RemoveFacetFromChannelResult": [
-      "Facet",
-      "FacetInUseError"
-    ],
-    "RemoveOptionGroupFromProductResult": [
-      "Product",
-      "ProductOptionInUseError"
-    ],
-    "RemoveOrderItemsResult": [
-      "Order",
-      "OrderModificationError"
-    ],
-    "SearchResultPrice": [
-      "PriceRange",
-      "SinglePrice"
-    ],
-    "SetCustomerForDraftOrderResult": [
-      "EmailAddressConflictError",
-      "Order"
-    ],
-    "SetOrderShippingMethodResult": [
-      "IneligibleShippingMethodError",
-      "NoActiveOrderError",
-      "Order",
-      "OrderModificationError"
-    ],
-    "SettlePaymentResult": [
-      "OrderStateTransitionError",
-      "Payment",
-      "PaymentStateTransitionError",
-      "SettlePaymentError"
-    ],
-    "SettleRefundResult": [
-      "Refund",
-      "RefundStateTransitionError"
-    ],
-    "StockMovement": [
-      "Allocation",
-      "Cancellation",
-      "Release",
-      "Return",
-      "Sale",
-      "StockAdjustment"
-    ],
-    "StockMovementItem": [
-      "Allocation",
-      "Cancellation",
-      "Release",
-      "Return",
-      "Sale",
-      "StockAdjustment"
-    ],
-    "TransitionFulfillmentToStateResult": [
-      "Fulfillment",
-      "FulfillmentStateTransitionError"
-    ],
-    "TransitionOrderToStateResult": [
-      "Order",
-      "OrderStateTransitionError"
-    ],
-    "TransitionPaymentToStateResult": [
-      "Payment",
-      "PaymentStateTransitionError"
-    ],
-    "UpdateChannelResult": [
-      "Channel",
-      "LanguageNotAvailableError"
-    ],
-    "UpdateCustomerResult": [
-      "Customer",
-      "EmailAddressConflictError"
-    ],
-    "UpdateGlobalSettingsResult": [
-      "ChannelDefaultLanguageError",
-      "GlobalSettings"
-    ],
-    "UpdateOrderItemsResult": [
-      "InsufficientStockError",
-      "NegativeQuantityError",
-      "Order",
-      "OrderLimitError",
-      "OrderModificationError"
-    ],
-    "UpdatePromotionResult": [
-      "MissingConditionsError",
-      "Promotion"
-    ]
-  }
+export interface PossibleTypesResultData {
+    possibleTypes: {
+        [key: string]: string[];
+    };
+}
+const result: PossibleTypesResultData = {
+    possibleTypes: {
+        AddFulfillmentToOrderResult: [
+            'CreateFulfillmentError',
+            'EmptyOrderLineSelectionError',
+            'Fulfillment',
+            'FulfillmentStateTransitionError',
+            'InsufficientStockOnHandError',
+            'InvalidFulfillmentHandlerError',
+            'ItemsAlreadyFulfilledError',
+        ],
+        AddManualPaymentToOrderResult: ['ManualPaymentStateError', 'Order'],
+        ApplyCouponCodeResult: [
+            'CouponCodeExpiredError',
+            'CouponCodeInvalidError',
+            'CouponCodeLimitError',
+            'Order',
+        ],
+        AuthenticationResult: ['CurrentUser', 'InvalidCredentialsError'],
+        CancelOrderResult: [
+            'CancelActiveOrderError',
+            'EmptyOrderLineSelectionError',
+            'MultipleOrderError',
+            'Order',
+            'OrderStateTransitionError',
+            'QuantityTooGreatError',
+        ],
+        CancelPaymentResult: ['CancelPaymentError', 'Payment', 'PaymentStateTransitionError'],
+        CreateAssetResult: ['Asset', 'MimeTypeError'],
+        CreateChannelResult: ['Channel', 'LanguageNotAvailableError'],
+        CreateCustomerResult: ['Customer', 'EmailAddressConflictError'],
+        CreatePromotionResult: ['MissingConditionsError', 'Promotion'],
+        CustomField: [
+            'BooleanCustomFieldConfig',
+            'DateTimeCustomFieldConfig',
+            'FloatCustomFieldConfig',
+            'IntCustomFieldConfig',
+            'LocaleStringCustomFieldConfig',
+            'LocaleTextCustomFieldConfig',
+            'RelationCustomFieldConfig',
+            'StringCustomFieldConfig',
+            'StructCustomFieldConfig',
+            'TextCustomFieldConfig',
+        ],
+        CustomFieldConfig: [
+            'BooleanCustomFieldConfig',
+            'DateTimeCustomFieldConfig',
+            'FloatCustomFieldConfig',
+            'IntCustomFieldConfig',
+            'LocaleStringCustomFieldConfig',
+            'LocaleTextCustomFieldConfig',
+            'RelationCustomFieldConfig',
+            'StringCustomFieldConfig',
+            'StructCustomFieldConfig',
+            'TextCustomFieldConfig',
+        ],
+        DuplicateEntityResult: ['DuplicateEntityError', 'DuplicateEntitySuccess'],
+        ErrorResult: [
+            'AlreadyRefundedError',
+            'CancelActiveOrderError',
+            'CancelPaymentError',
+            'ChannelDefaultLanguageError',
+            'CouponCodeExpiredError',
+            'CouponCodeInvalidError',
+            'CouponCodeLimitError',
+            'CreateFulfillmentError',
+            'DuplicateEntityError',
+            'EmailAddressConflictError',
+            'EmptyOrderLineSelectionError',
+            'FacetInUseError',
+            'FulfillmentStateTransitionError',
+            'GuestCheckoutError',
+            'IneligibleShippingMethodError',
+            'InsufficientStockError',
+            'InsufficientStockOnHandError',
+            'InvalidCredentialsError',
+            'InvalidFulfillmentHandlerError',
+            'ItemsAlreadyFulfilledError',
+            'LanguageNotAvailableError',
+            'ManualPaymentStateError',
+            'MimeTypeError',
+            'MissingConditionsError',
+            'MultipleOrderError',
+            'NativeAuthStrategyError',
+            'NegativeQuantityError',
+            'NoActiveOrderError',
+            'NoChangesSpecifiedError',
+            'NothingToRefundError',
+            'OrderLimitError',
+            'OrderModificationError',
+            'OrderModificationStateError',
+            'OrderStateTransitionError',
+            'PaymentMethodMissingError',
+            'PaymentOrderMismatchError',
+            'PaymentStateTransitionError',
+            'ProductOptionInUseError',
+            'QuantityTooGreatError',
+            'RefundAmountError',
+            'RefundOrderStateError',
+            'RefundPaymentIdMissingError',
+            'RefundStateTransitionError',
+            'SettlePaymentError',
+        ],
+        ModifyOrderResult: [
+            'CouponCodeExpiredError',
+            'CouponCodeInvalidError',
+            'CouponCodeLimitError',
+            'IneligibleShippingMethodError',
+            'InsufficientStockError',
+            'NegativeQuantityError',
+            'NoChangesSpecifiedError',
+            'Order',
+            'OrderLimitError',
+            'OrderModificationStateError',
+            'PaymentMethodMissingError',
+            'RefundPaymentIdMissingError',
+        ],
+        NativeAuthenticationResult: ['CurrentUser', 'InvalidCredentialsError', 'NativeAuthStrategyError'],
+        Node: [
+            'Address',
+            'Administrator',
+            'Allocation',
+            'Asset',
+            'AuthenticationMethod',
+            'Cancellation',
+            'Channel',
+            'Collection',
+            'Country',
+            'Customer',
+            'CustomerGroup',
+            'Facet',
+            'FacetValue',
+            'Fulfillment',
+            'HistoryEntry',
+            'Job',
+            'Order',
+            'OrderLine',
+            'OrderModification',
+            'Payment',
+            'PaymentMethod',
+            'Product',
+            'ProductOption',
+            'ProductOptionGroup',
+            'ProductVariant',
+            'Promotion',
+            'Province',
+            'Refund',
+            'Release',
+            'Return',
+            'Role',
+            'Sale',
+            'Seller',
+            'ShippingMethod',
+            'StockAdjustment',
+            'StockLevel',
+            'StockLocation',
+            'Surcharge',
+            'Tag',
+            'TaxCategory',
+            'TaxRate',
+            'User',
+            'Zone',
+        ],
+        PaginatedList: [
+            'AdministratorList',
+            'AssetList',
+            'ChannelList',
+            'CollectionList',
+            'CountryList',
+            'CustomerGroupList',
+            'CustomerList',
+            'FacetList',
+            'FacetValueList',
+            'HistoryEntryList',
+            'JobList',
+            'OrderList',
+            'PaymentMethodList',
+            'ProductList',
+            'ProductVariantList',
+            'PromotionList',
+            'ProvinceList',
+            'RoleList',
+            'SellerList',
+            'ShippingMethodList',
+            'StockLocationList',
+            'TagList',
+            'TaxCategoryList',
+            'TaxRateList',
+            'ZoneList',
+        ],
+        RefundOrderResult: [
+            'AlreadyRefundedError',
+            'MultipleOrderError',
+            'NothingToRefundError',
+            'OrderStateTransitionError',
+            'PaymentOrderMismatchError',
+            'QuantityTooGreatError',
+            'Refund',
+            'RefundAmountError',
+            'RefundOrderStateError',
+            'RefundStateTransitionError',
+        ],
+        Region: ['Country', 'Province'],
+        RemoveFacetFromChannelResult: ['Facet', 'FacetInUseError'],
+        RemoveOptionGroupFromProductResult: ['Product', 'ProductOptionInUseError'],
+        RemoveOrderItemsResult: ['Order', 'OrderModificationError'],
+        SearchResultPrice: ['PriceRange', 'SinglePrice'],
+        SetCustomerForDraftOrderResult: ['EmailAddressConflictError', 'Order'],
+        SetOrderShippingMethodResult: [
+            'IneligibleShippingMethodError',
+            'NoActiveOrderError',
+            'Order',
+            'OrderModificationError',
+        ],
+        SettlePaymentResult: [
+            'OrderStateTransitionError',
+            'Payment',
+            'PaymentStateTransitionError',
+            'SettlePaymentError',
+        ],
+        SettleRefundResult: ['Refund', 'RefundStateTransitionError'],
+        StockMovement: ['Allocation', 'Cancellation', 'Release', 'Return', 'Sale', 'StockAdjustment'],
+        StockMovementItem: ['Allocation', 'Cancellation', 'Release', 'Return', 'Sale', 'StockAdjustment'],
+        StructField: [
+            'BooleanStructFieldConfig',
+            'DateTimeStructFieldConfig',
+            'FloatStructFieldConfig',
+            'IntStructFieldConfig',
+            'StringStructFieldConfig',
+            'TextStructFieldConfig',
+        ],
+        StructFieldConfig: [
+            'BooleanStructFieldConfig',
+            'DateTimeStructFieldConfig',
+            'FloatStructFieldConfig',
+            'IntStructFieldConfig',
+            'StringStructFieldConfig',
+            'TextStructFieldConfig',
+        ],
+        TransitionFulfillmentToStateResult: ['Fulfillment', 'FulfillmentStateTransitionError'],
+        TransitionOrderToStateResult: ['Order', 'OrderStateTransitionError'],
+        TransitionPaymentToStateResult: ['Payment', 'PaymentStateTransitionError'],
+        UpdateChannelResult: ['Channel', 'LanguageNotAvailableError'],
+        UpdateCustomerResult: ['Customer', 'EmailAddressConflictError'],
+        UpdateGlobalSettingsResult: ['ChannelDefaultLanguageError', 'GlobalSettings'],
+        UpdateOrderItemsResult: [
+            'InsufficientStockError',
+            'NegativeQuantityError',
+            'Order',
+            'OrderLimitError',
+            'OrderModificationError',
+        ],
+        UpdatePromotionResult: ['MissingConditionsError', 'Promotion'],
+    },
 };
-      export default result;
-    
+export default result;

+ 2 - 0
packages/admin-ui/src/lib/core/src/common/utilities/custom-field-default-value.ts

@@ -30,6 +30,8 @@ export function getDefaultValue(type: CustomFieldType, isNullable?: boolean) {
             return isNullable ? null : new Date();
         case 'relation':
             return null;
+        case 'struct':
+            return {};
         default:
             assertNever(type);
     }

+ 52 - 0
packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts

@@ -640,6 +640,54 @@ export const RELATION_CUSTOM_FIELD_FRAGMENT = gql`
     ${CUSTOM_FIELD_CONFIG_FRAGMENT}
 `;
 
+export const STRUCT_CUSTOM_FIELD_FRAGMENT = gql`
+    fragment StructCustomField on StructCustomFieldConfig {
+        ...CustomFieldConfig
+        fields {
+            ... on StructField {
+                name
+                type
+                list
+                description {
+                    languageCode
+                    value
+                }
+                label {
+                    languageCode
+                    value
+                }
+                ui
+            }
+            ... on StringStructFieldConfig {
+                pattern
+                options {
+                    label {
+                        languageCode
+                        value
+                    }
+                    value
+                }
+            }
+            ... on IntStructFieldConfig {
+                intMin: min
+                intMax: max
+                intStep: step
+            }
+            ... on FloatStructFieldConfig {
+                floatMin: min
+                floatMax: max
+                floatStep: step
+            }
+            ... on DateTimeStructFieldConfig {
+                datetimeMin: min
+                datetimeMax: max
+                datetimeStep: step
+            }
+        }
+    }
+    ${CUSTOM_FIELD_CONFIG_FRAGMENT}
+`;
+
 export const ALL_CUSTOM_FIELDS_FRAGMENT = gql`
     fragment CustomFields on CustomField {
         ... on StringCustomFieldConfig {
@@ -669,6 +717,9 @@ export const ALL_CUSTOM_FIELDS_FRAGMENT = gql`
         ... on RelationCustomFieldConfig {
             ...RelationCustomField
         }
+        ... on StructCustomFieldConfig {
+            ...StructCustomField
+        }
     }
     ${STRING_CUSTOM_FIELD_FRAGMENT}
     ${LOCALE_STRING_CUSTOM_FIELD_FRAGMENT}
@@ -679,6 +730,7 @@ export const ALL_CUSTOM_FIELDS_FRAGMENT = gql`
     ${DATE_TIME_CUSTOM_FIELD_FRAGMENT}
     ${RELATION_CUSTOM_FIELD_FRAGMENT}
     ${LOCALE_TEXT_CUSTOM_FIELD_FRAGMENT}
+    ${STRUCT_CUSTOM_FIELD_FRAGMENT}
 `;
 
 export const GET_SERVER_CONFIG = gql`

+ 14 - 1
packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.ts

@@ -10,8 +10,8 @@ import {
 import {
     CustomFieldConfig,
     CustomFields,
-    EntityCustomFields,
     RelationCustomFieldFragment,
+    StructCustomFieldFragment,
 } from '../../common/generated-types';
 
 /**
@@ -75,6 +75,19 @@ export function addCustomFields(
                                       },
                                   }
                                 : {}),
+                            ...(customField.type === 'struct'
+                                ? {
+                                      selectionSet: {
+                                          kind: Kind.SELECTION_SET,
+                                          selections: (customField as StructCustomFieldFragment).fields.map(
+                                              f => ({
+                                                  kind: Kind.FIELD,
+                                                  name: { kind: Kind.NAME, value: f.name },
+                                              }),
+                                          ),
+                                      },
+                                  }
+                                : {}),
                         }) as FieldNode,
                 );
             if (!existingCustomFieldsField) {

+ 2 - 1
packages/admin-ui/src/lib/core/src/providers/data-table/data-table-filter-collection.ts

@@ -186,7 +186,8 @@ export class DataTableFilterCollection<FilterInput extends Record<string, any> =
                     filterType = { kind: 'text' };
                     break;
                 case 'relation':
-                    // Cannot sort relations
+                case 'struct':
+                    // Cannot filter relations
                     break;
                 default:
                     assertNever(type);

+ 1 - 0
packages/admin-ui/src/lib/core/src/providers/data-table/data-table-sort-collection.ts

@@ -68,6 +68,7 @@ export class DataTableSortCollection<
                     this.addSort({ name: config.name });
                     break;
                 case 'relation':
+                case 'struct':
                     // Cannot sort relations
                     break;
                 default:

+ 1 - 0
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -290,6 +290,7 @@ export * from './shared/dynamic-form-inputs/relation-form-input/relation-form-in
 export * from './shared/dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component';
 export * from './shared/dynamic-form-inputs/rich-text-form-input/rich-text-form-input.component';
 export * from './shared/dynamic-form-inputs/select-form-input/select-form-input.component';
+export * from './shared/dynamic-form-inputs/struct-form-input/struct-form-input.component';
 export * from './shared/dynamic-form-inputs/text-form-input/text-form-input.component';
 export * from './shared/dynamic-form-inputs/textarea-form-input/textarea-form-input.component';
 export * from './shared/pipes/asset-preview.pipe';

+ 35 - 14
packages/admin-ui/src/lib/core/src/shared/components/data-table-2/data-table-custom-field-column.component.html

@@ -21,20 +21,41 @@
                 {{ item.customFields[customField.name] | slice : 0 : 50 }}
             </ng-container>
             <ng-container *ngSwitchCase="'relation'">
-                <vdr-dropdown>
-                    <button
-                        class="btn btn-link btn-icon"
-                        vdrDropdownTrigger
-                        [title]="'common.details' | translate"
-                    >
-                        <clr-icon shape="details"></clr-icon>
-                    </button>
-                    <vdr-dropdown-menu>
-                        <div class="result-detail px-2 py-1">
-                            <vdr-object-tree [value]="item.customFields[customField.name]"></vdr-object-tree>
-                        </div>
-                    </vdr-dropdown-menu>
-                </vdr-dropdown>
+                <ng-container *ngIf="item.customFields[customField.name] as value">
+                    <vdr-dropdown>
+                        <button
+                            class="button-small"
+                            vdrDropdownTrigger
+                            [title]="'common.details' | translate"
+                        >
+                            <clr-icon shape="details"></clr-icon>
+                        </button>
+                        <vdr-dropdown-menu>
+                            <div class="result-detail px-2 py-1">
+                                <vdr-object-tree [value]="value"></vdr-object-tree>
+                            </div>
+                        </vdr-dropdown-menu>
+                    </vdr-dropdown>
+                </ng-container>
+
+            </ng-container>
+            <ng-container *ngSwitchCase="'struct'">
+                <ng-container *ngIf="item.customFields[customField.name] as value">
+                    <vdr-dropdown>
+                        <button
+                            class="button-small"
+                            vdrDropdownTrigger
+                            [title]="'common.details' | translate"
+                        >
+                            <clr-icon shape="details"></clr-icon>
+                        </button>
+                        <vdr-dropdown-menu>
+                            <div class="result-detail px-2 py-1">
+                                <vdr-object-tree [value]="value"></vdr-object-tree>
+                            </div>
+                        </vdr-dropdown-menu>
+                    </vdr-dropdown>
+                </ng-container>
             </ng-container>
             <ng-container *ngSwitchDefault>
                 {{ item.customFields[customField.name] }}

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/tabbed-custom-fields/tabbed-custom-fields.component.ts

@@ -46,6 +46,7 @@ export class TabbedCustomFieldsComponent implements OnInit {
             customField.type === 'text' ||
             customField.type === 'localeText' ||
             customField.type === 'relation' ||
+            customField.type === 'struct' ||
             (customField.ui?.component && !smallComponents.includes(customField.ui?.component))
         );
     }

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/default-form-inputs.ts

@@ -16,6 +16,7 @@ import { ProductSelectorFormInputComponent } from './product-selector-form-input
 import { RelationFormInputComponent } from './relation-form-input/relation-form-input.component';
 import { RichTextFormInputComponent } from './rich-text-form-input/rich-text-form-input.component';
 import { SelectFormInputComponent } from './select-form-input/select-form-input.component';
+import { StructFormInputComponent } from './struct-form-input/struct-form-input.component';
 import { TextFormInputComponent } from './text-form-input/text-form-input.component';
 import { TextareaFormInputComponent } from './textarea-form-input/textarea-form-input.component';
 
@@ -37,6 +38,7 @@ export const defaultFormInputs = [
     HtmlEditorFormInputComponent,
     ProductMultiSelectorFormInputComponent,
     CombinationModeFormInputComponent,
+    StructFormInputComponent,
 ];
 
 /**

+ 3 - 1
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts

@@ -280,7 +280,7 @@ export class DynamicFormInputComponent
                         ({
                             id: this.listId++,
                             control: new UntypedFormControl(getConfigArgValue(value)),
-                        } as InputListItem),
+                        }) as InputListItem,
                 );
                 this.renderList$.next();
             }
@@ -325,6 +325,8 @@ export class DynamicFormInputComponent
                 return { component: 'text-form-input' };
             case 'relation':
                 return { component: 'relation-form-input' };
+            case 'struct':
+                return { component: 'struct-form-input' };
             default:
                 assertNever(type);
         }

+ 21 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/struct-form-input/struct-form-input.component.html

@@ -0,0 +1,21 @@
+<div [formGroup]="structFormGroup" class="struct-container p-2">
+    <div *ngFor="let field of fields" class="flex mb-1">
+        <label class="struct-field-wrapper">
+            <div class="struct-field-label">
+                {{ field.def | customFieldLabel: (uiLanguage$ | async) }}
+                <vdr-help-tooltip
+                    *ngIf="field.def | customFieldDescription: (uiLanguage$ | async) as description"
+                    [content]="description"
+                ></vdr-help-tooltip>
+            </div>
+
+            <vdr-dynamic-form-input
+                [readonly]="false"
+                [control]="field.formControl"
+                [def]="field.def"
+                [formControlName]="field.def.name"
+            >
+            </vdr-dynamic-form-input>
+        </label>
+    </div>
+</div>

+ 15 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/struct-form-input/struct-form-input.component.scss

@@ -0,0 +1,15 @@
+.struct-container {
+    border-left: 2px solid var(--color-weight-100);
+}
+
+.struct-field-wrapper {
+    display: flex;
+    width: 100%;
+    align-items: baseline;
+    & > .struct-field-label {
+        flex: 1;
+    }
+    & > vdr-dynamic-form-input {
+        flex: 2;
+    }
+}

+ 62 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/struct-form-input/struct-form-input.component.ts

@@ -0,0 +1,62 @@
+import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
+import { FormControl, FormGroup, UntypedFormControl } from '@angular/forms';
+import { DefaultFormComponentConfig, DefaultFormComponentId } from '@vendure/common/lib/shared-types';
+import { Observable, Subscription } from 'rxjs';
+import { map } from 'rxjs/operators';
+
+import { FormInputComponent } from '../../../common/component-registry-types';
+import { LanguageCode, StructCustomFieldFragment } from '../../../common/generated-types';
+import { DataService } from '../../../data/providers/data.service';
+
+/**
+ * @description
+ * A checkbox input. The default input component for `boolean` fields.
+ *
+ * @docsCategory custom-input-components
+ * @docsPage default-inputs
+ */
+@Component({
+    selector: 'vdr-struct-form-input',
+    templateUrl: './struct-form-input.component.html',
+    styleUrls: ['./struct-form-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class StructFormInputComponent implements FormInputComponent, OnInit, OnDestroy {
+    static readonly id: DefaultFormComponentId = 'struct-form-input';
+    readonly: boolean;
+    formControl: UntypedFormControl;
+    config: DefaultFormComponentConfig<'struct-form-input'>;
+    uiLanguage$: Observable<LanguageCode>;
+    protected structFormGroup = new FormGroup({});
+    protected fields: Array<{
+        def: StructCustomFieldFragment['fields'][number];
+        formControl: FormControl;
+    }>;
+    private subscription: Subscription;
+
+    constructor(private dataService: DataService) {}
+
+    ngOnInit() {
+        this.uiLanguage$ = this.dataService.client
+            .uiState()
+            .stream$.pipe(map(({ uiState }) => uiState.language));
+
+        const value = this.formControl.value || {};
+
+        this.fields =
+            (this.config as unknown as StructCustomFieldFragment).fields?.map(field => {
+                const formControl = new FormControl(value[field.name]);
+                this.structFormGroup.addControl(field.name, formControl);
+                return { def: field, formControl };
+            }) ?? [];
+
+        this.structFormGroup.valueChanges.subscribe(value => {
+            this.formControl.setValue(value);
+            this.formControl.markAsDirty();
+        });
+    }
+
+    ngOnDestroy() {
+        this.subscription?.unsubscribe();
+    }
+}

+ 2 - 0
packages/admin-ui/src/lib/core/src/shared/shared.module.ts

@@ -146,6 +146,7 @@ import { RelationFormInputComponent } from './dynamic-form-inputs/relation-form-
 import { RelationSelectorDialogComponent } from './dynamic-form-inputs/relation-form-input/relation-selector-dialog/relation-selector-dialog.component';
 import { RichTextFormInputComponent } from './dynamic-form-inputs/rich-text-form-input/rich-text-form-input.component';
 import { SelectFormInputComponent } from './dynamic-form-inputs/select-form-input/select-form-input.component';
+import { StructFormInputComponent } from './dynamic-form-inputs/struct-form-input/struct-form-input.component';
 import { TextFormInputComponent } from './dynamic-form-inputs/text-form-input/text-form-input.component';
 import { TextareaFormInputComponent } from './dynamic-form-inputs/textarea-form-input/textarea-form-input.component';
 import { AssetPreviewPipe } from './pipes/asset-preview.pipe';
@@ -351,6 +352,7 @@ const DYNAMIC_FORM_INPUTS = [
     HtmlEditorFormInputComponent,
     ProductMultiSelectorFormInputComponent,
     CombinationModeFormInputComponent,
+    StructFormInputComponent,
 ];
 
 @NgModule({

+ 109 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -337,6 +337,16 @@ export type BooleanOperators = {
     isNull?: InputMaybe<Scalars['Boolean']['input']>;
 };
 
+export type BooleanStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if an attempting to cancel lines from an Order which is still active */
 export type CancelActiveOrderError = ErrorResult & {
     errorCode: ErrorCode;
@@ -1321,6 +1331,7 @@ export type CustomFieldConfig =
     | LocaleTextCustomFieldConfig
     | RelationCustomFieldConfig
     | StringCustomFieldConfig
+    | StructCustomFieldConfig
     | TextCustomFieldConfig;
 
 /**
@@ -1512,6 +1523,23 @@ export type DateTimeCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+/**
+ * Expects the same validation formats as the `<input type="datetime-local">` HTML element.
+ * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Additional_attributes
+ */
+export type DateTimeStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['String']['output']>;
+    min?: Maybe<Scalars['String']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type DeleteAssetInput = {
     assetId: Scalars['ID']['input'];
     deleteFromAllChannels?: InputMaybe<Scalars['Boolean']['input']>;
@@ -1830,6 +1858,19 @@ export type FloatCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type FloatStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Float']['output']>;
+    min?: Maybe<Scalars['Float']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Float']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type FulfillOrderInput = {
     handler: ConfigurableOperationInput;
     lines: Array<OrderLineInput>;
@@ -2024,6 +2065,19 @@ export type IntCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type IntStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Int']['output']>;
+    min?: Maybe<Scalars['Int']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if the user authentication credentials are not valid */
 export type InvalidCredentialsError = ErrorResult & {
     authenticationError: Scalars['String']['output'];
@@ -5707,6 +5761,51 @@ export type StringOperators = {
     regex?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type StringStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    length?: Maybe<Scalars['Int']['output']>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    options?: Maybe<Array<StringFieldOption>>;
+    pattern?: Maybe<Scalars['String']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructCustomFieldConfig = CustomField & {
+    description?: Maybe<Array<LocalizedString>>;
+    fields: Array<StructFieldConfig>;
+    internal?: Maybe<Scalars['Boolean']['output']>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    readonly?: Maybe<Scalars['Boolean']['output']>;
+    requiresPermission?: Maybe<Array<Permission>>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructField = {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list?: Maybe<Scalars['Boolean']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructFieldConfig =
+    | BooleanStructFieldConfig
+    | DateTimeStructFieldConfig
+    | FloatStructFieldConfig
+    | IntStructFieldConfig
+    | StringStructFieldConfig
+    | TextStructFieldConfig;
+
 /** Indicates that an operation succeeded, where we do not want to return any more specific information. */
 export type Success = {
     success: Scalars['Boolean']['output'];
@@ -5914,6 +6013,16 @@ export type TextCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type TextStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError;
 
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;

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

@@ -148,6 +148,17 @@ export type BooleanOperators = {
     isNull?: InputMaybe<Scalars['Boolean']['input']>;
 };
 
+export type BooleanStructFieldConfig = StructField & {
+    __typename?: 'BooleanStructFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type Channel = Node & {
     __typename?: 'Channel';
     availableCurrencyCodes: Array<CurrencyCode>;
@@ -764,6 +775,7 @@ export type CustomFieldConfig =
     | LocaleTextCustomFieldConfig
     | RelationCustomFieldConfig
     | StringCustomFieldConfig
+    | StructCustomFieldConfig
     | TextCustomFieldConfig;
 
 export type Customer = Node & {
@@ -883,6 +895,24 @@ export type DateTimeCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+/**
+ * Expects the same validation formats as the `<input type="datetime-local">` HTML element.
+ * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Additional_attributes
+ */
+export type DateTimeStructFieldConfig = StructField & {
+    __typename?: 'DateTimeStructFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['String']['output']>;
+    min?: Maybe<Scalars['String']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type DeletionResponse = {
     __typename?: 'DeletionResponse';
     message?: Maybe<Scalars['String']['output']>;
@@ -1119,6 +1149,20 @@ export type FloatCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type FloatStructFieldConfig = StructField & {
+    __typename?: 'FloatStructFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Float']['output']>;
+    min?: Maybe<Scalars['Float']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Float']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type Fulfillment = Node & {
     __typename?: 'Fulfillment';
     createdAt: Scalars['DateTime']['output'];
@@ -1302,6 +1346,20 @@ export type IntCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type IntStructFieldConfig = StructField & {
+    __typename?: 'IntStructFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Int']['output']>;
+    min?: Maybe<Scalars['Int']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if the user authentication credentials are not valid */
 export type InvalidCredentialsError = ErrorResult & {
     __typename?: 'InvalidCredentialsError';
@@ -3228,6 +3286,53 @@ export type StringOperators = {
     regex?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type StringStructFieldConfig = StructField & {
+    __typename?: 'StringStructFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    length?: Maybe<Scalars['Int']['output']>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    options?: Maybe<Array<StringFieldOption>>;
+    pattern?: Maybe<Scalars['String']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructCustomFieldConfig = CustomField & {
+    __typename?: 'StructCustomFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    fields: Array<StructFieldConfig>;
+    internal?: Maybe<Scalars['Boolean']['output']>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    readonly?: Maybe<Scalars['Boolean']['output']>;
+    requiresPermission?: Maybe<Array<Permission>>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructField = {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list?: Maybe<Scalars['Boolean']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructFieldConfig =
+    | BooleanStructFieldConfig
+    | DateTimeStructFieldConfig
+    | FloatStructFieldConfig
+    | IntStructFieldConfig
+    | StringStructFieldConfig
+    | TextStructFieldConfig;
+
 /** Indicates that an operation succeeded, where we do not want to return any more specific information. */
 export type Success = {
     __typename?: 'Success';
@@ -3311,6 +3416,17 @@ export type TextCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type TextStructFieldConfig = StructField & {
+    __typename?: 'TextStructFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 /**

+ 110 - 1
packages/common/src/generated-types.ts

@@ -332,6 +332,17 @@ export type BooleanOperators = {
   isNull?: InputMaybe<Scalars['Boolean']['input']>;
 };
 
+export type BooleanStructFieldConfig = StructField & {
+  __typename?: 'BooleanStructFieldConfig';
+  description?: Maybe<Array<LocalizedString>>;
+  label?: Maybe<Array<LocalizedString>>;
+  list: Scalars['Boolean']['output'];
+  name: Scalars['String']['output'];
+  nullable?: Maybe<Scalars['Boolean']['output']>;
+  type: Scalars['String']['output'];
+  ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if an attempting to cancel lines from an Order which is still active */
 export type CancelActiveOrderError = ErrorResult & {
   __typename?: 'CancelActiveOrderError';
@@ -1324,7 +1335,7 @@ export type CustomField = {
   ui?: Maybe<Scalars['JSON']['output']>;
 };
 
-export type CustomFieldConfig = BooleanCustomFieldConfig | DateTimeCustomFieldConfig | FloatCustomFieldConfig | IntCustomFieldConfig | LocaleStringCustomFieldConfig | LocaleTextCustomFieldConfig | RelationCustomFieldConfig | StringCustomFieldConfig | TextCustomFieldConfig;
+export type CustomFieldConfig = BooleanCustomFieldConfig | DateTimeCustomFieldConfig | FloatCustomFieldConfig | IntCustomFieldConfig | LocaleStringCustomFieldConfig | LocaleTextCustomFieldConfig | RelationCustomFieldConfig | StringCustomFieldConfig | StructCustomFieldConfig | TextCustomFieldConfig;
 
 /**
  * This type is deprecated in v2.2 in favor of the EntityCustomFields type,
@@ -1524,6 +1535,24 @@ export type DateTimeCustomFieldConfig = CustomField & {
   ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+/**
+ * Expects the same validation formats as the `<input type="datetime-local">` HTML element.
+ * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Additional_attributes
+ */
+export type DateTimeStructFieldConfig = StructField & {
+  __typename?: 'DateTimeStructFieldConfig';
+  description?: Maybe<Array<LocalizedString>>;
+  label?: Maybe<Array<LocalizedString>>;
+  list: Scalars['Boolean']['output'];
+  max?: Maybe<Scalars['String']['output']>;
+  min?: Maybe<Scalars['String']['output']>;
+  name: Scalars['String']['output'];
+  nullable?: Maybe<Scalars['Boolean']['output']>;
+  step?: Maybe<Scalars['Int']['output']>;
+  type: Scalars['String']['output'];
+  ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type DeleteAssetInput = {
   assetId: Scalars['ID']['input'];
   deleteFromAllChannels?: InputMaybe<Scalars['Boolean']['input']>;
@@ -1860,6 +1889,20 @@ export type FloatCustomFieldConfig = CustomField & {
   ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type FloatStructFieldConfig = StructField & {
+  __typename?: 'FloatStructFieldConfig';
+  description?: Maybe<Array<LocalizedString>>;
+  label?: Maybe<Array<LocalizedString>>;
+  list: Scalars['Boolean']['output'];
+  max?: Maybe<Scalars['Float']['output']>;
+  min?: Maybe<Scalars['Float']['output']>;
+  name: Scalars['String']['output'];
+  nullable?: Maybe<Scalars['Boolean']['output']>;
+  step?: Maybe<Scalars['Float']['output']>;
+  type: Scalars['String']['output'];
+  ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type FulfillOrderInput = {
   handler: ConfigurableOperationInput;
   lines: Array<OrderLineInput>;
@@ -2066,6 +2109,20 @@ export type IntCustomFieldConfig = CustomField & {
   ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type IntStructFieldConfig = StructField & {
+  __typename?: 'IntStructFieldConfig';
+  description?: Maybe<Array<LocalizedString>>;
+  label?: Maybe<Array<LocalizedString>>;
+  list: Scalars['Boolean']['output'];
+  max?: Maybe<Scalars['Int']['output']>;
+  min?: Maybe<Scalars['Int']['output']>;
+  name: Scalars['String']['output'];
+  nullable?: Maybe<Scalars['Boolean']['output']>;
+  step?: Maybe<Scalars['Int']['output']>;
+  type: Scalars['String']['output'];
+  ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if the user authentication credentials are not valid */
 export type InvalidCredentialsError = ErrorResult & {
   __typename?: 'InvalidCredentialsError';
@@ -6032,6 +6089,47 @@ export type StringOperators = {
   regex?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type StringStructFieldConfig = StructField & {
+  __typename?: 'StringStructFieldConfig';
+  description?: Maybe<Array<LocalizedString>>;
+  label?: Maybe<Array<LocalizedString>>;
+  length?: Maybe<Scalars['Int']['output']>;
+  list: Scalars['Boolean']['output'];
+  name: Scalars['String']['output'];
+  nullable?: Maybe<Scalars['Boolean']['output']>;
+  options?: Maybe<Array<StringFieldOption>>;
+  pattern?: Maybe<Scalars['String']['output']>;
+  type: Scalars['String']['output'];
+  ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructCustomFieldConfig = CustomField & {
+  __typename?: 'StructCustomFieldConfig';
+  description?: Maybe<Array<LocalizedString>>;
+  fields: Array<StructFieldConfig>;
+  internal?: Maybe<Scalars['Boolean']['output']>;
+  label?: Maybe<Array<LocalizedString>>;
+  list: Scalars['Boolean']['output'];
+  name: Scalars['String']['output'];
+  nullable?: Maybe<Scalars['Boolean']['output']>;
+  readonly?: Maybe<Scalars['Boolean']['output']>;
+  requiresPermission?: Maybe<Array<Permission>>;
+  type: Scalars['String']['output'];
+  ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructField = {
+  description?: Maybe<Array<LocalizedString>>;
+  label?: Maybe<Array<LocalizedString>>;
+  list?: Maybe<Scalars['Boolean']['output']>;
+  name: Scalars['String']['output'];
+  nullable?: Maybe<Scalars['Boolean']['output']>;
+  type: Scalars['String']['output'];
+  ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructFieldConfig = BooleanStructFieldConfig | DateTimeStructFieldConfig | FloatStructFieldConfig | IntStructFieldConfig | StringStructFieldConfig | TextStructFieldConfig;
+
 /** Indicates that an operation succeeded, where we do not want to return any more specific information. */
 export type Success = {
   __typename?: 'Success';
@@ -6251,6 +6349,17 @@ export type TextCustomFieldConfig = CustomField & {
   ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type TextStructFieldConfig = StructField & {
+  __typename?: 'TextStructFieldConfig';
+  description?: Maybe<Array<LocalizedString>>;
+  label?: Maybe<Array<LocalizedString>>;
+  list: Scalars['Boolean']['output'];
+  name: Scalars['String']['output'];
+  nullable?: Maybe<Scalars['Boolean']['output']>;
+  type: Scalars['String']['output'];
+  ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError;
 
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;

+ 9 - 3
packages/common/src/shared-types.ts

@@ -88,11 +88,12 @@ export type ID = string | number;
  * string       | varchar                               | String
  * localeString | varchar                               | String
  * text         | longtext(m), text(p,s)                | String
- * localeText    | longtext(m), text(p,s)                | String
+ * localeText   | longtext(m), text(p,s)                | String
  * int          | int                                   | Int
  * float        | double precision                      | Float
  * boolean      | tinyint (m), bool (p), boolean (s)    | Boolean
  * datetime     | datetime (m,s), timestamp (p)         | DateTime
+ * struct       | json (m), jsonb (p), text (s)         | JSON
  * relation     | many-to-one / many-to-many relation   | As specified in config
  *
  * Additionally, the CustomFieldType also dictates which [configuration options](/reference/typescript-api/custom-fields/#custom-field-config-properties)
@@ -109,7 +110,10 @@ export type CustomFieldType =
     | 'datetime'
     | 'relation'
     | 'text'
-    | 'localeText';
+    | 'localeText'
+    | 'struct';
+
+export type StructFieldType = 'string' | 'int' | 'float' | 'boolean' | 'datetime' | 'text';
 
 /**
  * @description
@@ -149,7 +153,8 @@ export type DefaultFormComponentId =
     | 'text-form-input'
     | 'textarea-form-input'
     | 'product-multi-form-input'
-    | 'combination-mode-form-input';
+    | 'combination-mode-form-input'
+    | 'struct-form-input';
 
 /**
  * @description
@@ -181,6 +186,7 @@ type DefaultFormConfigHash = {
         selectionMode?: 'product' | 'variant';
     };
     'combination-mode-form-input': Record<string, never>;
+    'struct-form-input': Record<string, never>;
 };
 
 export type DefaultFormComponentUiConfig<T extends DefaultFormComponentId | string> =

+ 448 - 0
packages/core/e2e/custom-field-struct.e2e-spec.ts

@@ -0,0 +1,448 @@
+import { mergeConfig } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
+import { fixPostgresTimezone } from './utils/fix-pg-timezone';
+
+fixPostgresTimezone();
+
+const validateInjectorSpy = vi.fn();
+
+const customConfig = mergeConfig(testConfig(), {
+    dbConnectionOptions: {
+        timezone: 'Z',
+    },
+    customFields: {
+        Product: [
+            {
+                name: 'attributes',
+                type: 'struct',
+                fields: [
+                    { name: 'color', type: 'string' },
+                    { name: 'size', type: 'string' },
+                    { name: 'material', type: 'string' },
+                    { name: 'weight', type: 'int' },
+                    { name: 'isDownloadable', type: 'boolean' },
+                    { name: 'releaseDate', type: 'datetime' },
+                ],
+            },
+        ],
+        Customer: [
+            {
+                name: 'coupons',
+                type: 'struct',
+                list: true,
+                fields: [
+                    { name: 'code', type: 'string' },
+                    { name: 'discount', type: 'int' },
+                    { name: 'used', type: 'boolean' },
+                ],
+            },
+            {
+                name: 'company',
+                type: 'struct',
+                fields: [{ name: 'phoneNumbers', type: 'string', list: true }],
+            },
+            {
+                name: 'withValidation',
+                type: 'struct',
+                fields: [
+                    { name: 'stringWithPattern', type: 'string', pattern: '^[0-9][a-z]+$' },
+                    { name: 'numberWithRange', type: 'int', min: 1, max: 10 },
+                    {
+                        name: 'stringWithValidationFn',
+                        type: 'string',
+                        validate: value => {
+                            if (value !== 'valid') {
+                                return `The value ['${value as string}'] is not valid`;
+                            }
+                        },
+                    },
+                ],
+            },
+        ],
+    },
+});
+
+describe('Custom field struct type', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment(customConfig);
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('globalSettings.serverConfig.customFieldConfig resolves struct fields', async () => {
+        const { globalSettings } = await adminClient.query(gql`
+            query {
+                globalSettings {
+                    serverConfig {
+                        customFieldConfig {
+                            Product {
+                                ... on CustomField {
+                                    name
+                                    type
+                                    list
+                                }
+                                ... on StructCustomFieldConfig {
+                                    fields {
+                                        ... on StructField {
+                                            name
+                                            type
+                                            list
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        `);
+
+        expect(globalSettings.serverConfig.customFieldConfig.Product).toEqual([
+            {
+                name: 'attributes',
+                type: 'struct',
+                list: false,
+                fields: [
+                    { name: 'color', type: 'string', list: false },
+                    { name: 'size', type: 'string', list: false },
+                    { name: 'material', type: 'string', list: false },
+                    { name: 'weight', type: 'int', list: false },
+                    { name: 'isDownloadable', type: 'boolean', list: false },
+                    { name: 'releaseDate', type: 'datetime', list: false },
+                ],
+            },
+        ]);
+    });
+
+    it('globalSettings.serverConfig.entityCustomFields resolves struct fields', async () => {
+        const { globalSettings } = await adminClient.query(gql`
+            query {
+                globalSettings {
+                    serverConfig {
+                        entityCustomFields {
+                            entityName
+                            customFields {
+                                ... on CustomField {
+                                    name
+                                    type
+                                    list
+                                }
+                                ... on StructCustomFieldConfig {
+                                    fields {
+                                        ... on StructField {
+                                            name
+                                            type
+                                            list
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        `);
+
+        const productEntry = globalSettings.serverConfig.entityCustomFields.find(
+            (e: any) => e.entityName === 'Product',
+        );
+        expect(productEntry).toEqual({
+            entityName: 'Product',
+            customFields: [
+                {
+                    name: 'attributes',
+                    type: 'struct',
+                    list: false,
+                    fields: [
+                        { name: 'color', type: 'string', list: false },
+                        { name: 'size', type: 'string', list: false },
+                        { name: 'material', type: 'string', list: false },
+                        { name: 'weight', type: 'int', list: false },
+                        { name: 'isDownloadable', type: 'boolean', list: false },
+                        { name: 'releaseDate', type: 'datetime', list: false },
+                    ],
+                },
+            ],
+        });
+    });
+
+    it('struct fields initially null', async () => {
+        const result = await adminClient.query(gql`
+            query {
+                product(id: "T_1") {
+                    id
+                    customFields {
+                        attributes {
+                            color
+                            size
+                            material
+                            weight
+                            isDownloadable
+                            releaseDate
+                        }
+                    }
+                }
+            }
+        `);
+
+        expect(result.product.customFields.attributes).toEqual({
+            color: null,
+            size: null,
+            material: null,
+            weight: null,
+            isDownloadable: null,
+            releaseDate: null,
+        });
+    });
+
+    it('update all fields in struct', async () => {
+        const result = await adminClient.query(gql`
+            mutation {
+                updateProduct(
+                    input: {
+                        id: "T_1"
+                        customFields: {
+                            attributes: {
+                                color: "red"
+                                size: "L"
+                                material: "cotton"
+                                weight: 123
+                                isDownloadable: true
+                                releaseDate: "2021-01-01T12:00:00.000Z"
+                            }
+                        }
+                    }
+                ) {
+                    id
+                    customFields {
+                        attributes {
+                            color
+                            size
+                            material
+                            weight
+                            isDownloadable
+                            releaseDate
+                        }
+                    }
+                }
+            }
+        `);
+
+        expect(result.updateProduct.customFields.attributes).toEqual({
+            color: 'red',
+            size: 'L',
+            material: 'cotton',
+            weight: 123,
+            isDownloadable: true,
+            releaseDate: '2021-01-01T12:00:00.000Z',
+        });
+    });
+
+    it('partial update of struct fields nulls missing fields', async () => {
+        const result = await adminClient.query(gql`
+            mutation {
+                updateProduct(
+                    input: {
+                        id: "T_1"
+                        customFields: { attributes: { color: "red", size: "L", material: "cotton" } }
+                    }
+                ) {
+                    id
+                    customFields {
+                        attributes {
+                            color
+                            size
+                            material
+                            weight
+                            isDownloadable
+                            releaseDate
+                        }
+                    }
+                }
+            }
+        `);
+
+        expect(result.updateProduct.customFields.attributes).toEqual({
+            color: 'red',
+            size: 'L',
+            material: 'cotton',
+            weight: null,
+            isDownloadable: null,
+            releaseDate: null,
+        });
+    });
+
+    describe('struct list', () => {
+        it('is initially an empty array', async () => {
+            const result = await adminClient.query(gql`
+                query {
+                    customer(id: "T_1") {
+                        customFields {
+                            coupons {
+                                code
+                                discount
+                                used
+                            }
+                        }
+                    }
+                }
+            `);
+            expect(result.customer.customFields.coupons).toEqual([]);
+        });
+
+        it('sets list values', async () => {
+            const result = await adminClient.query(gql`
+                mutation {
+                    updateCustomer(
+                        input: {
+                            id: "T_1"
+                            customFields: {
+                                coupons: [
+                                    { code: "ABC", discount: 10, used: false }
+                                    { code: "DEF", discount: 20, used: true }
+                                ]
+                            }
+                        }
+                    ) {
+                        ... on Customer {
+                            id
+                            customFields {
+                                coupons {
+                                    code
+                                    discount
+                                    used
+                                }
+                            }
+                        }
+                    }
+                }
+            `);
+
+            expect(result.updateCustomer.customFields).toEqual({
+                coupons: [
+                    { code: 'ABC', discount: 10, used: false },
+                    { code: 'DEF', discount: 20, used: true },
+                ],
+            });
+        });
+    });
+
+    describe('struct field list', () => {
+        it('is initially an empty array', async () => {
+            const result = await adminClient.query(gql`
+                query {
+                    customer(id: "T_1") {
+                        id
+                        customFields {
+                            company {
+                                phoneNumbers
+                            }
+                        }
+                    }
+                }
+            `);
+
+            expect(result.customer.customFields.company).toEqual({
+                phoneNumbers: [],
+            });
+        });
+
+        it('set list field values', async () => {
+            const result = await adminClient.query(gql`
+                mutation {
+                    updateCustomer(
+                        input: { id: "T_1", customFields: { company: { phoneNumbers: ["123", "456"] } } }
+                    ) {
+                        ... on Customer {
+                            id
+                            customFields {
+                                company {
+                                    phoneNumbers
+                                }
+                            }
+                        }
+                    }
+                }
+            `);
+
+            expect(result.updateCustomer.customFields.company).toEqual({
+                phoneNumbers: ['123', '456'],
+            });
+        });
+    });
+
+    describe('struct field validation', () => {
+        it(
+            'string pattern',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query(gql`
+                    mutation {
+                        updateCustomer(
+                            input: {
+                                id: "T_1"
+                                customFields: { withValidation: { stringWithPattern: "abc" } }
+                            }
+                        ) {
+                            ... on Customer {
+                                id
+                            }
+                        }
+                    }
+                `);
+            }, `The custom field "stringWithPattern" value ["abc"] does not match the pattern [^[0-9][a-z]+$]`),
+        );
+
+        it(
+            'number range',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query(gql`
+                    mutation {
+                        updateCustomer(
+                            input: { id: "T_1", customFields: { withValidation: { numberWithRange: 15 } } }
+                        ) {
+                            ... on Customer {
+                                id
+                            }
+                        }
+                    }
+                `);
+            }, `The custom field "numberWithRange" value [15] is greater than the maximum [10]`),
+        );
+        it(
+            'validate function',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query(gql`
+                    mutation {
+                        updateCustomer(
+                            input: {
+                                id: "T_1"
+                                customFields: { withValidation: { stringWithValidationFn: "bad" } }
+                            }
+                        ) {
+                            ... on Customer {
+                                id
+                            }
+                        }
+                    }
+                `);
+            }, `The value ['bad'] is not valid`),
+        );
+    });
+});

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

@@ -337,6 +337,16 @@ export type BooleanOperators = {
     isNull?: InputMaybe<Scalars['Boolean']['input']>;
 };
 
+export type BooleanStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if an attempting to cancel lines from an Order which is still active */
 export type CancelActiveOrderError = ErrorResult & {
     errorCode: ErrorCode;
@@ -1321,6 +1331,7 @@ export type CustomFieldConfig =
     | LocaleTextCustomFieldConfig
     | RelationCustomFieldConfig
     | StringCustomFieldConfig
+    | StructCustomFieldConfig
     | TextCustomFieldConfig;
 
 /**
@@ -1512,6 +1523,23 @@ export type DateTimeCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+/**
+ * Expects the same validation formats as the `<input type="datetime-local">` HTML element.
+ * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Additional_attributes
+ */
+export type DateTimeStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['String']['output']>;
+    min?: Maybe<Scalars['String']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type DeleteAssetInput = {
     assetId: Scalars['ID']['input'];
     deleteFromAllChannels?: InputMaybe<Scalars['Boolean']['input']>;
@@ -1830,6 +1858,19 @@ export type FloatCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type FloatStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Float']['output']>;
+    min?: Maybe<Scalars['Float']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Float']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type FulfillOrderInput = {
     handler: ConfigurableOperationInput;
     lines: Array<OrderLineInput>;
@@ -2024,6 +2065,19 @@ export type IntCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type IntStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Int']['output']>;
+    min?: Maybe<Scalars['Int']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if the user authentication credentials are not valid */
 export type InvalidCredentialsError = ErrorResult & {
     authenticationError: Scalars['String']['output'];
@@ -5707,6 +5761,51 @@ export type StringOperators = {
     regex?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type StringStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    length?: Maybe<Scalars['Int']['output']>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    options?: Maybe<Array<StringFieldOption>>;
+    pattern?: Maybe<Scalars['String']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructCustomFieldConfig = CustomField & {
+    description?: Maybe<Array<LocalizedString>>;
+    fields: Array<StructFieldConfig>;
+    internal?: Maybe<Scalars['Boolean']['output']>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    readonly?: Maybe<Scalars['Boolean']['output']>;
+    requiresPermission?: Maybe<Array<Permission>>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructField = {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list?: Maybe<Scalars['Boolean']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructFieldConfig =
+    | BooleanStructFieldConfig
+    | DateTimeStructFieldConfig
+    | FloatStructFieldConfig
+    | IntStructFieldConfig
+    | StringStructFieldConfig
+    | TextStructFieldConfig;
+
 /** Indicates that an operation succeeded, where we do not want to return any more specific information. */
 export type Success = {
     success: Scalars['Boolean']['output'];
@@ -5914,6 +6013,16 @@ export type TextCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type TextStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError;
 
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
@@ -8136,6 +8245,7 @@ export type GetGlobalSettingsQuery = {
                     | { name: string }
                     | { name: string }
                     | { name: string }
+                    | { name: string }
                 >;
             };
         };
@@ -8676,6 +8786,7 @@ export type GlobalSettingsFragment = {
                 | { name: string }
                 | { name: string }
                 | { name: string }
+                | { name: string }
             >;
         };
     };
@@ -10342,6 +10453,7 @@ export type UpdateGlobalSettingsMutation = {
                           | { name: string }
                           | { name: string }
                           | { name: string }
+                          | { name: string }
                       >;
                   };
               };

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

@@ -142,6 +142,16 @@ export type BooleanOperators = {
     isNull?: InputMaybe<Scalars['Boolean']['input']>;
 };
 
+export type BooleanStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type Channel = Node & {
     availableCurrencyCodes: Array<CurrencyCode>;
     availableLanguageCodes?: Maybe<Array<LanguageCode>>;
@@ -740,6 +750,7 @@ export type CustomFieldConfig =
     | LocaleTextCustomFieldConfig
     | RelationCustomFieldConfig
     | StringCustomFieldConfig
+    | StructCustomFieldConfig
     | TextCustomFieldConfig;
 
 export type Customer = Node & {
@@ -855,6 +866,23 @@ export type DateTimeCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+/**
+ * Expects the same validation formats as the `<input type="datetime-local">` HTML element.
+ * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Additional_attributes
+ */
+export type DateTimeStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['String']['output']>;
+    min?: Maybe<Scalars['String']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type DeletionResponse = {
     message?: Maybe<Scalars['String']['output']>;
     result: DeletionResult;
@@ -1080,6 +1108,19 @@ export type FloatCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type FloatStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Float']['output']>;
+    min?: Maybe<Scalars['Float']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Float']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type Fulfillment = Node & {
     createdAt: Scalars['DateTime']['output'];
     customFields?: Maybe<Scalars['JSON']['output']>;
@@ -1252,6 +1293,19 @@ export type IntCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type IntStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Int']['output']>;
+    min?: Maybe<Scalars['Int']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if the user authentication credentials are not valid */
 export type InvalidCredentialsError = ErrorResult & {
     authenticationError: Scalars['String']['output'];
@@ -3113,6 +3167,51 @@ export type StringOperators = {
     regex?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type StringStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    length?: Maybe<Scalars['Int']['output']>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    options?: Maybe<Array<StringFieldOption>>;
+    pattern?: Maybe<Scalars['String']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructCustomFieldConfig = CustomField & {
+    description?: Maybe<Array<LocalizedString>>;
+    fields: Array<StructFieldConfig>;
+    internal?: Maybe<Scalars['Boolean']['output']>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    readonly?: Maybe<Scalars['Boolean']['output']>;
+    requiresPermission?: Maybe<Array<Permission>>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructField = {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list?: Maybe<Scalars['Boolean']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructFieldConfig =
+    | BooleanStructFieldConfig
+    | DateTimeStructFieldConfig
+    | FloatStructFieldConfig
+    | IntStructFieldConfig
+    | StringStructFieldConfig
+    | TextStructFieldConfig;
+
 /** Indicates that an operation succeeded, where we do not want to return any more specific information. */
 export type Success = {
     success: Scalars['Boolean']['output'];
@@ -3187,6 +3286,16 @@ export type TextCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type TextStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 /**

+ 38 - 4
packages/core/src/api/common/validate-custom-field-value.ts

@@ -9,7 +9,11 @@ import {
     IntCustomFieldConfig,
     LocaleStringCustomFieldConfig,
     StringCustomFieldConfig,
+    StringStructFieldConfig,
+    StructCustomFieldConfig,
+    StructFieldConfig,
     TypedCustomFieldConfig,
+    TypedStructFieldConfig,
 } from '../../config/custom-field/custom-field-types';
 
 import { RequestContext } from './request-context';
@@ -43,14 +47,20 @@ export async function validateCustomFieldValue(
     if (config.list === true && Array.isArray(value)) {
         for (const singleValue of value) {
             validateSingleValue(config, singleValue);
+            if (config.type === 'struct') {
+                await validateStructField(config, singleValue, injector, ctx);
+            }
         }
     } else {
         validateSingleValue(config, value);
+        if (config.type === 'struct') {
+            await validateStructField(config, value, injector, ctx);
+        }
     }
     await validateCustomFunction(config as TypedCustomFieldConfig<any, any>, value, injector, ctx);
 }
 
-function validateSingleValue(config: CustomFieldConfig, value: any) {
+function validateSingleValue(config: CustomFieldConfig | StructFieldConfig, value: any) {
     switch (config.type) {
         case 'string':
         case 'localeString':
@@ -68,17 +78,41 @@ function validateSingleValue(config: CustomFieldConfig, value: any) {
         case 'text':
         case 'localeText':
             break;
+        // Structs get validated separately
+        case 'struct':
+            break;
         default:
             assertNever(config);
     }
 }
 
-async function validateCustomFunction<T extends TypedCustomFieldConfig<any, any>>(
-    config: T,
+async function validateStructField(
+    config: StructCustomFieldConfig,
     value: any,
     injector: Injector,
     ctx: RequestContext,
 ) {
+    for (const field of config.fields ?? []) {
+        const fieldValue = value[field.name];
+        if (fieldValue !== undefined) {
+            validateSingleValue(field, fieldValue);
+        }
+        if (typeof field.validate === 'function') {
+            const error = await (field.validate as any)(fieldValue, injector, ctx);
+            if (typeof error === 'string') {
+                throw new UserInputError(error);
+            }
+            if (Array.isArray(error)) {
+                const localizedError = error.find(e => e.languageCode === ctx.languageCode) || error[0];
+                throw new UserInputError(localizedError.value);
+            }
+        }
+    }
+}
+
+async function validateCustomFunction<
+    T extends TypedCustomFieldConfig<any, any> | TypedStructFieldConfig<any, any>,
+>(config: T, value: any, injector: Injector, ctx: RequestContext) {
     if (typeof config.validate === 'function') {
         const error = await config.validate(value, injector, ctx);
         if (typeof error === 'string') {
@@ -92,7 +126,7 @@ async function validateCustomFunction<T extends TypedCustomFieldConfig<any, any>
 }
 
 function validateStringField(
-    config: StringCustomFieldConfig | LocaleStringCustomFieldConfig,
+    config: StringCustomFieldConfig | LocaleStringCustomFieldConfig | StringStructFieldConfig,
     value: string,
 ): void {
     const { pattern } = config;

+ 85 - 8
packages/core/src/api/config/generate-resolvers.ts

@@ -11,7 +11,11 @@ import {
 import { shopErrorOperationTypeResolvers } from '../../common/error/generated-graphql-shop-errors';
 import { Translatable } from '../../common/types/locale-types';
 import { ConfigService } from '../../config/config.service';
-import { CustomFieldConfig, RelationCustomFieldConfig } from '../../config/custom-field/custom-field-types';
+import {
+    CustomFieldConfig,
+    RelationCustomFieldConfig,
+    StructCustomFieldConfig,
+} from '../../config/custom-field/custom-field-types';
 import { Logger } from '../../config/logger/vendure-logger';
 import { Region } from '../../entity/region/region.entity';
 import { getPluginAPIExtensions } from '../../plugin/plugin-metadata';
@@ -96,6 +100,27 @@ export async function generateResolvers(
                     return 'DateTimeCustomFieldConfig';
                 case 'relation':
                     return 'RelationCustomFieldConfig';
+                case 'struct':
+                    return 'StructCustomFieldConfig';
+            }
+        },
+    };
+
+    const structFieldConfigResolveType = {
+        __resolveType(value: any) {
+            switch (value.type) {
+                case 'string':
+                    return 'StringStructFieldConfig';
+                case 'text':
+                    return 'TextStructFieldConfig';
+                case 'int':
+                    return 'IntStructFieldConfig';
+                case 'float':
+                    return 'FloatStructFieldConfig';
+                case 'boolean':
+                    return 'BooleanStructFieldConfig';
+                case 'datetime':
+                    return 'DateTimeStructFieldConfig';
             }
         },
     };
@@ -117,6 +142,7 @@ export async function generateResolvers(
         },
         CustomFieldConfig: customFieldsConfigResolveType,
         CustomField: customFieldsConfigResolveType,
+        StructFieldConfig: structFieldConfigResolveType,
         ErrorResult: {
             __resolveType(value: ErrorResult) {
                 return value.__typename;
@@ -125,7 +151,7 @@ export async function generateResolvers(
         Region: regionResolveType,
     };
 
-    const customFieldRelationResolvers = generateCustomFieldRelationResolvers(
+    const customFieldResolvers = generateCustomFieldResolvers(
         configService,
         customFieldRelationResolverService,
         schema,
@@ -135,12 +161,12 @@ export async function generateResolvers(
         StockMovementItem: stockMovementResolveType,
         StockMovement: stockMovementResolveType,
         ...adminErrorOperationTypeResolvers,
-        ...customFieldRelationResolvers.adminResolvers,
+        ...customFieldResolvers.adminResolvers,
     };
 
     const shopResolvers = {
         ...shopErrorOperationTypeResolvers,
-        ...customFieldRelationResolvers.shopResolvers,
+        ...customFieldResolvers.shopResolvers,
     };
 
     const resolvers =
@@ -152,10 +178,17 @@ export async function generateResolvers(
 
 /**
  * @description
- * Based on the CustomFields config, this function dynamically creates resolver functions to perform
- * a DB query to fetch the related entity for any custom fields of type "relation".
+ * Based on the CustomFields config, this function dynamically creates resolver functions to handle
+ * the resolution of more complex custom field types:
+ *
+ * - `relation` type custom fields: These are resolved by querying the database for the related entity.
+ * - `struct` type custom fields: These are resolved by iterating over the fields of the struct and
+ *   resolving each field individually.
+ *
+ * This function also performs additional checks to ensure that only users with the correct permissions
+ * are able to access custom fields, and that custom field data is not being leaked via the JSON type.
  */
-function generateCustomFieldRelationResolvers(
+function generateCustomFieldResolvers(
     configService: ConfigService,
     customFieldRelationResolverService: CustomFieldRelationResolverService,
     schema: GraphQLSchema,
@@ -232,6 +265,46 @@ function generateCustomFieldRelationResolvers(
                         entityId,
                     });
                 };
+            } else if (isStructType(fieldDef)) {
+                resolver = async (source: any) => {
+                    const fields = fieldDef.fields;
+                    function buildStructObject(valueFromDb: any) {
+                        const result: Record<string, any> = {};
+                        for (const structField of fields) {
+                            const value = valueFromDb?.[structField.name];
+                            if (structField.list === true) {
+                                const valueArray = Array.isArray(value) ? value : value ? [value] : [];
+                                result[structField.name] = valueArray;
+                            } else {
+                                result[structField.name] = value ?? null;
+                            }
+                        }
+                        return result;
+                    }
+                    if (fieldDef.list === true) {
+                        const structArray = Array.isArray(source[fieldDef.name])
+                            ? source[fieldDef.name]
+                            : source[fieldDef.name]
+                              ? [source[fieldDef.name]]
+                              : [];
+                        source[fieldDef.name] = structArray.map(buildStructObject);
+                    } else {
+                        source[fieldDef.name] = buildStructObject(source[fieldDef.name]);
+                    }
+
+                    return source[fieldDef.name];
+                };
+                adminResolvers[customFieldTypeName] = {
+                    ...adminResolvers[customFieldTypeName],
+                    [fieldDef.name]: resolver,
+                } as any;
+
+                if (fieldDef.public !== false && !excludeFromShopApi) {
+                    shopResolvers[customFieldTypeName] = {
+                        ...shopResolvers[customFieldTypeName],
+                        [fieldDef.name]: resolver,
+                    } as any;
+                }
             } else {
                 resolver = async (source: any, args: any, context: any) => {
                     const ctx = internal_getRequestContext(context.req);
@@ -270,7 +343,7 @@ function generateCustomFieldRelationResolvers(
 
 function getCustomScalars(configService: ConfigService, apiType: 'admin' | 'shop') {
     return getPluginAPIExtensions(configService.plugins, apiType)
-        .map(e => (typeof e.scalars === 'function' ? e.scalars() : e.scalars ?? {}))
+        .map(e => (typeof e.scalars === 'function' ? e.scalars() : (e.scalars ?? {})))
         .reduce(
             (all, scalarMap) => ({
                 ...all,
@@ -284,6 +357,10 @@ function isRelationalType(input: CustomFieldConfig): input is RelationCustomFiel
     return input.type === 'relation';
 }
 
+function isStructType(input: CustomFieldConfig): input is StructCustomFieldConfig {
+    return input.type === 'struct';
+}
+
 function isTranslatable(input: unknown): input is Translatable {
     return typeof input === 'object' && input != null && input.hasOwnProperty('translations');
 }

+ 118 - 23
packages/core/src/api/config/graphql-custom-fields.ts

@@ -9,7 +9,13 @@ import {
     parse,
 } from 'graphql';
 
-import { CustomFieldConfig, CustomFields } from '../../config/custom-field/custom-field-types';
+import {
+    CustomFieldConfig,
+    CustomFields,
+    StructCustomFieldConfig,
+    RelationCustomFieldConfig,
+    StructFieldConfig,
+} from '../../config/custom-field/custom-field-types';
 import { Logger } from '../../config/logger/vendure-logger';
 
 import { getCustomFieldsConfigWithoutInterfaces } from './get-custom-fields-config-without-interfaces';
@@ -84,13 +90,29 @@ export function addGraphQLCustomFields(
         );
         const writeableLocalizedFields = localizedFields.filter(field => !field.readonly);
         const writeableNonLocalizedFields = nonLocalizedFields.filter(field => !field.readonly);
-        const filterableFields = customEntityFields.filter(field => field.type !== 'relation');
+        const sortableFields = customEntityFields.filter(
+            field => field.list !== true && field.type !== 'struct',
+        );
+        const filterableFields = customEntityFields.filter(
+            field => field.type !== 'relation' && field.type !== 'struct',
+        );
+        const structCustomFields = customEntityFields.filter(
+            (f): f is StructCustomFieldConfig => f.type === 'struct',
+        );
 
         if (schema.getType(entityName)) {
             if (customEntityFields.length) {
+                for (const structCustomField of structCustomFields) {
+                    customFieldTypeDefs += `
+                        type ${getStructTypeName(entityName, structCustomField)} {
+                            ${mapToStructFields(structCustomField.fields, wrapListType(getGraphQlTypeForStructField))}
+                        }
+                    `;
+                }
+
                 customFieldTypeDefs += `
                     type ${entityName}CustomFields {
-                        ${mapToFields(customEntityFields, wrapListType(getGraphQlType))}
+                        ${mapToFields(customEntityFields, wrapListType(getGraphQlType(entityName)))}
                     }
 
                     extend type ${entityName} {
@@ -109,7 +131,7 @@ export function addGraphQLCustomFields(
         if (localizedFields.length && schema.getType(`${entityName}Translation`)) {
             customFieldTypeDefs += `
                     type ${entityName}TranslationCustomFields {
-                         ${mapToFields(localizedFields, wrapListType(getGraphQlType))}
+                         ${mapToFields(localizedFields, wrapListType(getGraphQlType(entityName)))}
                     }
 
                     extend type ${entityName}Translation {
@@ -120,11 +142,19 @@ export function addGraphQLCustomFields(
 
         if (schema.getType(`Create${entityName}Input`)) {
             if (writeableNonLocalizedFields.length) {
+                for (const structCustomField of structCustomFields) {
+                    customFieldTypeDefs += `
+                        input ${getStructInputName(entityName, structCustomField)} {
+                            ${mapToStructFields(structCustomField.fields, wrapListType(getGraphQlInputType(entityName)))}
+                        }
+                    `;
+                }
+
                 customFieldTypeDefs += `
                     input Create${entityName}CustomFieldsInput {
                        ${mapToFields(
                            writeableNonLocalizedFields,
-                           wrapListType(getGraphQlInputType),
+                           wrapListType(getGraphQlInputType(entityName)),
                            getGraphQlInputName,
                        )}
                     }
@@ -148,7 +178,7 @@ export function addGraphQLCustomFields(
                     input Update${entityName}CustomFieldsInput {
                        ${mapToFields(
                            writeableNonLocalizedFields,
-                           wrapListType(getGraphQlInputType),
+                           wrapListType(getGraphQlInputType(entityName)),
                            getGraphQlInputName,
                        )}
                     }
@@ -166,13 +196,12 @@ export function addGraphQLCustomFields(
             }
         }
 
-        const customEntityNonListFields = customEntityFields.filter(f => f.list !== true);
-        if (customEntityNonListFields.length && schema.getType(`${entityName}SortParameter`)) {
+        if (sortableFields.length && schema.getType(`${entityName}SortParameter`)) {
             // Sorting list fields makes no sense, so we only add "sort" fields
             // to non-list fields.
             customFieldTypeDefs += `
                     extend input ${entityName}SortParameter {
-                         ${mapToFields(customEntityNonListFields, () => 'SortOrder')}
+                         ${mapToFields(sortableFields, () => 'SortOrder')}
                     }
                 `;
         }
@@ -196,7 +225,7 @@ export function addGraphQLCustomFields(
                     if (writeableLocalizedFields.length) {
                         customFieldTypeDefs += `
                             input ${inputName}CustomFields {
-                                ${mapToFields(writeableLocalizedFields, wrapListType(getGraphQlType))}
+                                ${mapToFields(writeableLocalizedFields, wrapListType(getGraphQlType(entityName)))}
                             }
 
                             extend input ${inputName} {
@@ -322,7 +351,7 @@ export function addRegisterCustomerCustomFieldsInput(
     }
     const customFieldTypeDefs = `
         input RegisterCustomerCustomFieldsInput {
-            ${mapToFields(publicWritableCustomFields, wrapListType(getGraphQlInputType), getGraphQlInputName)}
+            ${mapToFields(publicWritableCustomFields, wrapListType(getGraphQlInputType('Customer')), getGraphQlInputName)}
         }
 
         extend input RegisterCustomerInput {
@@ -380,7 +409,7 @@ export function addOrderLineCustomFieldsInput(
         fields: publicCustomFields.reduce((fields, field) => {
             const name = getGraphQlInputName(field);
             // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-            const primitiveType = schema.getType(getGraphQlInputType(field))!;
+            const primitiveType = schema.getType(getGraphQlInputType('OrderLine')(field))!;
             const type = field.list === true ? new GraphQLList(primitiveType) : primitiveType;
             return { ...fields, [name]: { type } };
         }, {}),
@@ -526,6 +555,27 @@ function mapToFields(
     return res.join('\n');
 }
 
+/**
+ * Maps an array of CustomFieldConfig objects into a string of SDL fields.
+ */
+function mapToStructFields(
+    fieldDefs: StructFieldConfig[],
+    typeFn: (def: StructFieldConfig) => string | undefined,
+    nameFn?: (def: Pick<StructFieldConfig, 'name' | 'type' | 'list'>) => string,
+): string {
+    const res = fieldDefs
+        .map(field => {
+            const type = typeFn(field);
+            if (!type) {
+                return;
+            }
+            const name = nameFn ? nameFn(field) : field.name;
+            return `${name}: ${type}`;
+        })
+        .filter(x => x != null);
+    return res.join('\n');
+}
+
 function getFilterOperator(config: CustomFieldConfig): string | undefined {
     switch (config.type) {
         case 'datetime':
@@ -541,6 +591,7 @@ function getFilterOperator(config: CustomFieldConfig): string | undefined {
         case 'float':
             return config.list ? 'NumberListOperators' : 'NumberOperators';
         case 'relation':
+        case 'struct':
             return undefined;
         default:
             assertNever(config);
@@ -548,14 +599,23 @@ function getFilterOperator(config: CustomFieldConfig): string | undefined {
     return 'String';
 }
 
-function getGraphQlInputType(config: CustomFieldConfig): string {
-    return config.type === 'relation' ? 'ID' : getGraphQlType(config);
+function getGraphQlInputType(entityName: string) {
+    return (config: CustomFieldConfig): string => {
+        switch (config.type) {
+            case 'relation':
+                return 'ID';
+            case 'struct':
+                return getStructInputName(entityName, config);
+            default:
+                return getGraphQlType(entityName)(config);
+        }
+    };
 }
 
-function wrapListType(
-    getTypeFn: (def: CustomFieldConfig) => string | undefined,
-): (def: CustomFieldConfig) => string | undefined {
-    return (def: CustomFieldConfig) => {
+function wrapListType<T extends CustomFieldConfig | StructFieldConfig>(
+    getTypeFn: (def: T) => string | undefined,
+): (def: T) => string | undefined {
+    return (def: T) => {
         const type = getTypeFn(def);
         if (!type) {
             return;
@@ -564,12 +624,37 @@ function wrapListType(
     };
 }
 
-function getGraphQlType(config: CustomFieldConfig): string {
+function getGraphQlType(entityName: string) {
+    return (config: CustomFieldConfig): string => {
+        switch (config.type) {
+            case 'string':
+            case 'localeString':
+            case 'text':
+            case 'localeText':
+                return 'String';
+            case 'datetime':
+                return 'DateTime';
+            case 'boolean':
+                return 'Boolean';
+            case 'int':
+                return 'Int';
+            case 'float':
+                return 'Float';
+            case 'relation':
+                return config.graphQLType || config.entity.name;
+            case 'struct':
+                return getStructTypeName(entityName, config);
+            default:
+                assertNever(config);
+        }
+        return 'String';
+    };
+}
+
+function getGraphQlTypeForStructField(config: StructFieldConfig): string {
     switch (config.type) {
         case 'string':
-        case 'localeString':
         case 'text':
-        case 'localeText':
             return 'String';
         case 'datetime':
             return 'DateTime';
@@ -579,10 +664,20 @@ function getGraphQlType(config: CustomFieldConfig): string {
             return 'Int';
         case 'float':
             return 'Float';
-        case 'relation':
-            return config.graphQLType || config.entity.name;
         default:
             assertNever(config);
     }
     return 'String';
 }
+
+function getStructTypeName(entityName: string, fieldDef: StructCustomFieldConfig): string {
+    return `${entityName}${pascalCase(fieldDef.name)}Struct`;
+}
+
+function getStructInputName(entityName: string, fieldDef: StructCustomFieldConfig): string {
+    return `${entityName}${pascalCase(fieldDef.name)}StructInput`;
+}
+
+function pascalCase(input: string) {
+    return input.charAt(0).toUpperCase() + input.slice(1);
+}

+ 18 - 2
packages/core/src/api/resolvers/admin/global-settings.resolver.ts

@@ -3,6 +3,7 @@ import {
     CustomFields as GraphQLCustomFields,
     CustomFieldConfig as GraphQLCustomFieldConfig,
     RelationCustomFieldConfig as GraphQLRelationCustomFieldConfig,
+    StructCustomFieldConfig as GraphQLStructCustomFieldConfig,
     EntityCustomFields,
     MutationUpdateGlobalSettingsArgs,
     Permission,
@@ -29,6 +30,7 @@ import {
     CustomFieldConfig,
     CustomFields,
     RelationCustomFieldConfig,
+    StructCustomFieldConfig,
 } from '../../../config/custom-field/custom-field-types';
 import { GlobalSettings } from '../../../entity/global-settings/global-settings.entity';
 import { ChannelService } from '../../../service/services/channel.service';
@@ -112,6 +114,9 @@ export class GlobalSettingsResolver {
                         c.entity = c.entity.name;
                         c.scalarFields = this.getScalarFieldsOfType(info, c.graphQLType || c.entity);
                     }
+                    if (c.type === 'struct') {
+                        c.fields = c.fields.map((f: any) => ({ ...f, list: !!f.list }));
+                    }
                     return c;
                 });
         }
@@ -134,8 +139,8 @@ export class GlobalSettingsResolver {
                         c.requiresPermission = Array.isArray(requiresPermission)
                             ? requiresPermission
                             : !!requiresPermission
-                            ? [requiresPermission]
-                            : [];
+                              ? [requiresPermission]
+                              : [];
                         return c;
                     })
                     .map(c => {
@@ -149,6 +154,9 @@ export class GlobalSettingsResolver {
                                 c.graphQLType || c.entity.name,
                             );
                         }
+                        if (this.isStructGraphQLType(customFieldConfig) && this.isStructConfigType(c)) {
+                            customFieldConfig.fields = c.fields.map(f => ({ ...f, list: !!f.list as any }));
+                        }
                         return customFieldConfig;
                     });
                 return { entityName: entityType, customFields: customFieldsConfig };
@@ -162,10 +170,18 @@ export class GlobalSettingsResolver {
         return config.type === 'relation';
     }
 
+    private isStructGraphQLType(config: GraphQLCustomFieldConfig): config is GraphQLStructCustomFieldConfig {
+        return config.type === 'struct';
+    }
+
     private isRelationConfigType(config: CustomFieldConfig): config is RelationCustomFieldConfig {
         return config.type === 'relation';
     }
 
+    private isStructConfigType(config: CustomFieldConfig): config is StructCustomFieldConfig {
+        return config.type === 'struct';
+    }
+
     private getScalarFieldsOfType(info: GraphQLResolveInfo, typeName: string): string[] {
         const type = info.schema.getType(typeName);
 

+ 99 - 0
packages/core/src/api/schema/common/custom-field-types.graphql

@@ -149,6 +149,104 @@ type LocaleTextCustomFieldConfig implements CustomField {
     ui: JSON
 }
 
+interface StructField {
+    name: String!
+    type: String!
+    list: Boolean
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    ui: JSON
+}
+
+type StringStructFieldConfig implements StructField {
+    name: String!
+    type: String!
+    list: Boolean!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    length: Int
+    pattern: String
+    options: [StringFieldOption!]
+    ui: JSON
+}
+
+type IntStructFieldConfig implements StructField {
+    name: String!
+    type: String!
+    list: Boolean!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    min: Int
+    max: Int
+    step: Int
+    ui: JSON
+}
+type FloatStructFieldConfig implements StructField {
+    name: String!
+    type: String!
+    list: Boolean!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    min: Float
+    max: Float
+    step: Float
+    ui: JSON
+}
+type BooleanStructFieldConfig implements StructField {
+    name: String!
+    type: String!
+    list: Boolean!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    ui: JSON
+}
+"""
+Expects the same validation formats as the `<input type="datetime-local">` HTML element.
+See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Additional_attributes
+"""
+type DateTimeStructFieldConfig implements StructField {
+    name: String!
+    type: String!
+    list: Boolean!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    min: String
+    max: String
+    step: Int
+    ui: JSON
+}
+
+type TextStructFieldConfig implements StructField {
+    name: String!
+    type: String!
+    list: Boolean!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    ui: JSON
+}
+
+union StructFieldConfig =
+    StringStructFieldConfig
+    | IntStructFieldConfig
+    | FloatStructFieldConfig
+    | BooleanStructFieldConfig
+    | DateTimeStructFieldConfig
+    | TextStructFieldConfig
+
+type StructCustomFieldConfig implements CustomField {
+    name: String!
+    type: String!
+    list: Boolean!
+    fields: [StructFieldConfig!]!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    readonly: Boolean
+    internal: Boolean
+    nullable: Boolean
+    requiresPermission: [Permission!]
+    ui: JSON
+}
+
 type LocalizedString {
     languageCode: LanguageCode!
     value: String!
@@ -164,3 +262,4 @@ union CustomFieldConfig =
     | RelationCustomFieldConfig
     | TextCustomFieldConfig
     | LocaleTextCustomFieldConfig
+    | StructCustomFieldConfig

+ 108 - 3
packages/core/src/config/custom-field/custom-field-types.ts

@@ -11,6 +11,14 @@ import {
     RelationCustomFieldConfig as GraphQLRelationCustomFieldConfig,
     StringCustomFieldConfig as GraphQLStringCustomFieldConfig,
     TextCustomFieldConfig as GraphQLTextCustomFieldConfig,
+    StructCustomFieldConfig as GraphQLStructCustomFieldConfig,
+    StructField as GraphQLStructField,
+    StringStructFieldConfig as GraphQLStringStructFieldConfig,
+    IntStructFieldConfig as GraphQLIntStructFieldConfig,
+    TextStructFieldConfig as GraphQLTextStructFieldConfig,
+    FloatStructFieldConfig as GraphQLFloatStructFieldConfig,
+    BooleanStructFieldConfig as GraphQLBooleanStructFieldConfig,
+    DateTimeStructFieldConfig as GraphQLDateTimeStructFieldConfig,
 } from '@vendure/common/lib/generated-types';
 import {
     CustomFieldsObject,
@@ -18,6 +26,7 @@ import {
     DefaultFormComponentId,
     Type,
     UiComponentConfig,
+    StructFieldType,
 } from '@vendure/common/lib/shared-types';
 
 import { RequestContext } from '../../api/common/request-context';
@@ -25,7 +34,7 @@ import { Injector } from '../../common/injector';
 import { VendureEntity } from '../../entity/base/base.entity';
 
 // prettier-ignore
-export type DefaultValueType<T extends CustomFieldType> =
+export type DefaultValueType<T extends CustomFieldType | StructFieldType> =
     T extends 'string' | 'localeString' | 'text' | 'localeText' ? string :
         T extends 'int' | 'float' ? number :
             T extends 'boolean' ? boolean :
@@ -82,7 +91,9 @@ export type TypedCustomListFieldConfig<
 > = BaseTypedCustomFieldConfig<T, C> & {
     list?: true;
     defaultValue?: Array<DefaultValueType<T>>;
-    validate?: (value: Array<DefaultValueType<T>>) => string | LocalizedString[] | void;
+    validate?: (
+        value: Array<DefaultValueType<T>>,
+    ) => string | LocalizedString[] | void | Promise<string | LocalizedString[] | void>;
 };
 
 export type TypedCustomFieldConfig<
@@ -91,6 +102,7 @@ export type TypedCustomFieldConfig<
 > = BaseTypedCustomFieldConfig<T, C> &
     (TypedCustomSingleFieldConfig<T, C> | TypedCustomListFieldConfig<T, C>);
 
+// Type-safe custom field type definitions
 export type StringCustomFieldConfig = TypedCustomFieldConfig<'string', GraphQLStringCustomFieldConfig>;
 export type LocaleStringCustomFieldConfig = TypedCustomFieldConfig<
     'localeString',
@@ -115,6 +127,98 @@ export type RelationCustomFieldConfig = TypedCustomFieldConfig<
     inverseSide?: string | ((object: any) => any);
 };
 
+// Struct field definitions
+export type BaseTypedStructFieldConfig<T extends StructFieldType, C extends GraphQLStructField> = Omit<
+    C,
+    '__typename' | 'list'
+> & {
+    type: T;
+    ui?: UiComponentConfig<DefaultFormComponentId | string>;
+};
+export type TypedStructSingleFieldConfig<
+    T extends StructFieldType,
+    C extends GraphQLStructField,
+> = BaseTypedStructFieldConfig<T, C> & {
+    list?: false;
+    validate?: (
+        value: DefaultValueType<T>,
+        injector: Injector,
+        ctx: RequestContext,
+    ) => string | LocalizedString[] | void | Promise<string | LocalizedString[] | void>;
+};
+
+export type TypedStructListFieldConfig<
+    T extends StructFieldType,
+    C extends GraphQLStructField,
+> = BaseTypedStructFieldConfig<T, C> & {
+    list?: true;
+    validate?: (
+        value: Array<DefaultValueType<T>>,
+    ) => string | LocalizedString[] | void | Promise<string | LocalizedString[] | void>;
+};
+
+export type TypedStructFieldConfig<
+    T extends StructFieldType,
+    C extends GraphQLStructField,
+> = BaseTypedStructFieldConfig<T, C> &
+    (TypedStructSingleFieldConfig<T, C> | TypedStructListFieldConfig<T, C>);
+
+export type StringStructFieldConfig = TypedStructFieldConfig<'string', GraphQLStringStructFieldConfig>;
+export type TextStructFieldConfig = TypedStructFieldConfig<'text', GraphQLTextStructFieldConfig>;
+export type IntStructFieldConfig = TypedStructFieldConfig<'int', GraphQLIntStructFieldConfig>;
+export type FloatStructFieldConfig = TypedStructFieldConfig<'float', GraphQLFloatStructFieldConfig>;
+export type BooleanStructFieldConfig = TypedStructFieldConfig<'boolean', GraphQLBooleanStructFieldConfig>;
+export type DateTimeStructFieldConfig = TypedStructFieldConfig<'datetime', GraphQLDateTimeStructFieldConfig>;
+
+/**
+ * @description
+ * Configures an individual field of a "struct" custom field. The individual fields share
+ * the same API as the top-level custom fields, with the exception that they do not support the
+ * `readonly`, `internal`, `nullable`, `unique` and `requiresPermission` options.
+ *
+ * @example
+ * ```ts
+ * const customFields: CustomFields = {
+ *   Product: [
+ *     {
+ *       name: 'specifications',
+ *       type: 'struct',
+ *       fields: [
+ *         { name: 'processor', type: 'string' },
+ *         { name: 'ram', type: 'string' },
+ *         { name: 'screenSize', type: 'float' },
+ *       ],
+ *     },
+ *   ],
+ * };
+ * ```
+ *
+ *
+ * @docsCategory custom-fields
+ * @since 3.1.0
+ */
+export type StructFieldConfig =
+    | StringStructFieldConfig
+    | TextStructFieldConfig
+    | IntStructFieldConfig
+    | FloatStructFieldConfig
+    | BooleanStructFieldConfig
+    | DateTimeStructFieldConfig;
+
+/**
+ * @description
+ * Configures a "struct" custom field.
+ *
+ * @docsCategory custom-fields
+ * @since 3.1.0
+ */
+export type StructCustomFieldConfig = TypedCustomFieldConfig<
+    'struct',
+    Omit<GraphQLStructCustomFieldConfig, 'fields'>
+> & {
+    fields: StructFieldConfig[];
+};
+
 /**
  * @description
  * An object used to configure a custom field.
@@ -130,7 +234,8 @@ export type CustomFieldConfig =
     | FloatCustomFieldConfig
     | BooleanCustomFieldConfig
     | DateTimeCustomFieldConfig
-    | RelationCustomFieldConfig;
+    | RelationCustomFieldConfig
+    | StructCustomFieldConfig;
 
 /**
  * @description

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

@@ -56,7 +56,7 @@ function registerCustomFieldsForEntity(
                     }
                 } else {
                     const options: ColumnOptions = {
-                        type: list ? 'simple-json' : getColumnType(dbEngine, customField.type),
+                        type: getColumnType(dbEngine, customField.type, list ?? false),
                         default: getDefault(customField, dbEngine),
                         name,
                         nullable: nullable === false ? false : true,
@@ -156,7 +156,11 @@ function formatDefaultDatetime(dbEngine: DataSourceOptions['type'], datetime: an
 function getColumnType(
     dbEngine: DataSourceOptions['type'],
     type: Exclude<CustomFieldType, 'relation'>,
+    isList: boolean,
 ): ColumnType {
+    if (isList && type !== 'struct') {
+        return 'simple-json';
+    }
     switch (type) {
         case 'string':
         case 'localeString':
@@ -195,6 +199,18 @@ function getColumnType(
                 default:
                     return 'datetime';
             }
+        case 'struct':
+            switch (dbEngine) {
+                case 'postgres':
+                    return 'jsonb';
+                case 'mysql':
+                case 'mariadb':
+                    return 'json';
+                case 'sqlite':
+                case 'sqljs':
+                default:
+                    return 'simple-json';
+            }
         default:
             assertNever(type);
     }

+ 109 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -337,6 +337,16 @@ export type BooleanOperators = {
     isNull?: InputMaybe<Scalars['Boolean']['input']>;
 };
 
+export type BooleanStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if an attempting to cancel lines from an Order which is still active */
 export type CancelActiveOrderError = ErrorResult & {
     errorCode: ErrorCode;
@@ -1321,6 +1331,7 @@ export type CustomFieldConfig =
     | LocaleTextCustomFieldConfig
     | RelationCustomFieldConfig
     | StringCustomFieldConfig
+    | StructCustomFieldConfig
     | TextCustomFieldConfig;
 
 /**
@@ -1512,6 +1523,23 @@ export type DateTimeCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+/**
+ * Expects the same validation formats as the `<input type="datetime-local">` HTML element.
+ * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Additional_attributes
+ */
+export type DateTimeStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['String']['output']>;
+    min?: Maybe<Scalars['String']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type DeleteAssetInput = {
     assetId: Scalars['ID']['input'];
     deleteFromAllChannels?: InputMaybe<Scalars['Boolean']['input']>;
@@ -1830,6 +1858,19 @@ export type FloatCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type FloatStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Float']['output']>;
+    min?: Maybe<Scalars['Float']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Float']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type FulfillOrderInput = {
     handler: ConfigurableOperationInput;
     lines: Array<OrderLineInput>;
@@ -2024,6 +2065,19 @@ export type IntCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type IntStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Int']['output']>;
+    min?: Maybe<Scalars['Int']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if the user authentication credentials are not valid */
 export type InvalidCredentialsError = ErrorResult & {
     authenticationError: Scalars['String']['output'];
@@ -5707,6 +5761,51 @@ export type StringOperators = {
     regex?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type StringStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    length?: Maybe<Scalars['Int']['output']>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    options?: Maybe<Array<StringFieldOption>>;
+    pattern?: Maybe<Scalars['String']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructCustomFieldConfig = CustomField & {
+    description?: Maybe<Array<LocalizedString>>;
+    fields: Array<StructFieldConfig>;
+    internal?: Maybe<Scalars['Boolean']['output']>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    readonly?: Maybe<Scalars['Boolean']['output']>;
+    requiresPermission?: Maybe<Array<Permission>>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructField = {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list?: Maybe<Scalars['Boolean']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructFieldConfig =
+    | BooleanStructFieldConfig
+    | DateTimeStructFieldConfig
+    | FloatStructFieldConfig
+    | IntStructFieldConfig
+    | StringStructFieldConfig
+    | TextStructFieldConfig;
+
 /** Indicates that an operation succeeded, where we do not want to return any more specific information. */
 export type Success = {
     success: Scalars['Boolean']['output'];
@@ -5914,6 +6013,16 @@ export type TextCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type TextStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError;
 
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;

+ 109 - 0
packages/payments-plugin/e2e/graphql/generated-admin-types.ts

@@ -341,6 +341,16 @@ export type BooleanOperators = {
     isNull?: InputMaybe<Scalars['Boolean']['input']>;
 };
 
+export type BooleanStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if an attempting to cancel lines from an Order which is still active */
 export type CancelActiveOrderError = ErrorResult & {
     errorCode: ErrorCode;
@@ -1325,6 +1335,7 @@ export type CustomFieldConfig =
     | LocaleTextCustomFieldConfig
     | RelationCustomFieldConfig
     | StringCustomFieldConfig
+    | StructCustomFieldConfig
     | TextCustomFieldConfig;
 
 /**
@@ -1516,6 +1527,23 @@ export type DateTimeCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+/**
+ * Expects the same validation formats as the `<input type="datetime-local">` HTML element.
+ * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Additional_attributes
+ */
+export type DateTimeStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['String']['output']>;
+    min?: Maybe<Scalars['String']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type DeleteAssetInput = {
     assetId: Scalars['ID']['input'];
     deleteFromAllChannels?: InputMaybe<Scalars['Boolean']['input']>;
@@ -1835,6 +1863,19 @@ export type FloatCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type FloatStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Float']['output']>;
+    min?: Maybe<Scalars['Float']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Float']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type FulfillOrderInput = {
     handler: ConfigurableOperationInput;
     lines: Array<OrderLineInput>;
@@ -2029,6 +2070,19 @@ export type IntCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type IntStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Int']['output']>;
+    min?: Maybe<Scalars['Int']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if the user authentication credentials are not valid */
 export type InvalidCredentialsError = ErrorResult & {
     authenticationError: Scalars['String']['output'];
@@ -5782,6 +5836,51 @@ export type StringOperators = {
     regex?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type StringStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    length?: Maybe<Scalars['Int']['output']>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    options?: Maybe<Array<StringFieldOption>>;
+    pattern?: Maybe<Scalars['String']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructCustomFieldConfig = CustomField & {
+    description?: Maybe<Array<LocalizedString>>;
+    fields: Array<StructFieldConfig>;
+    internal?: Maybe<Scalars['Boolean']['output']>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    readonly?: Maybe<Scalars['Boolean']['output']>;
+    requiresPermission?: Maybe<Array<Permission>>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructField = {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list?: Maybe<Scalars['Boolean']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructFieldConfig =
+    | BooleanStructFieldConfig
+    | DateTimeStructFieldConfig
+    | FloatStructFieldConfig
+    | IntStructFieldConfig
+    | StringStructFieldConfig
+    | TextStructFieldConfig;
+
 /** Indicates that an operation succeeded, where we do not want to return any more specific information. */
 export type Success = {
     success: Scalars['Boolean']['output'];
@@ -5989,6 +6088,16 @@ export type TextCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type TextStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type TransitionFulfillmentToStateResult = Fulfillment | FulfillmentStateTransitionError;
 
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;

+ 109 - 0
packages/payments-plugin/e2e/graphql/generated-shop-types.ts

@@ -146,6 +146,16 @@ export type BooleanOperators = {
     isNull?: InputMaybe<Scalars['Boolean']['input']>;
 };
 
+export type BooleanStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type Channel = Node & {
     availableCurrencyCodes: Array<CurrencyCode>;
     availableLanguageCodes?: Maybe<Array<LanguageCode>>;
@@ -744,6 +754,7 @@ export type CustomFieldConfig =
     | LocaleTextCustomFieldConfig
     | RelationCustomFieldConfig
     | StringCustomFieldConfig
+    | StructCustomFieldConfig
     | TextCustomFieldConfig;
 
 export type Customer = Node & {
@@ -859,6 +870,23 @@ export type DateTimeCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+/**
+ * Expects the same validation formats as the `<input type="datetime-local">` HTML element.
+ * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Additional_attributes
+ */
+export type DateTimeStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['String']['output']>;
+    min?: Maybe<Scalars['String']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type DeletionResponse = {
     message?: Maybe<Scalars['String']['output']>;
     result: DeletionResult;
@@ -1084,6 +1112,19 @@ export type FloatCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type FloatStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Float']['output']>;
+    min?: Maybe<Scalars['Float']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Float']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type Fulfillment = Node & {
     createdAt: Scalars['DateTime']['output'];
     customFields?: Maybe<Scalars['JSON']['output']>;
@@ -1256,6 +1297,19 @@ export type IntCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type IntStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Int']['output']>;
+    min?: Maybe<Scalars['Int']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if the user authentication credentials are not valid */
 export type InvalidCredentialsError = ErrorResult & {
     authenticationError: Scalars['String']['output'];
@@ -3187,6 +3241,51 @@ export type StringOperators = {
     regex?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type StringStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    length?: Maybe<Scalars['Int']['output']>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    options?: Maybe<Array<StringFieldOption>>;
+    pattern?: Maybe<Scalars['String']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructCustomFieldConfig = CustomField & {
+    description?: Maybe<Array<LocalizedString>>;
+    fields: Array<StructFieldConfig>;
+    internal?: Maybe<Scalars['Boolean']['output']>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    readonly?: Maybe<Scalars['Boolean']['output']>;
+    requiresPermission?: Maybe<Array<Permission>>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructField = {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list?: Maybe<Scalars['Boolean']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructFieldConfig =
+    | BooleanStructFieldConfig
+    | DateTimeStructFieldConfig
+    | FloatStructFieldConfig
+    | IntStructFieldConfig
+    | StringStructFieldConfig
+    | TextStructFieldConfig;
+
 /** Indicates that an operation succeeded, where we do not want to return any more specific information. */
 export type Success = {
     success: Scalars['Boolean']['output'];
@@ -3261,6 +3360,16 @@ export type TextCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type TextStructFieldConfig = StructField & {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 /**

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

@@ -152,6 +152,17 @@ export type BooleanOperators = {
     isNull?: InputMaybe<Scalars['Boolean']['input']>;
 };
 
+export type BooleanStructFieldConfig = StructField & {
+    __typename?: 'BooleanStructFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type Channel = Node & {
     __typename?: 'Channel';
     availableCurrencyCodes: Array<CurrencyCode>;
@@ -768,6 +779,7 @@ export type CustomFieldConfig =
     | LocaleTextCustomFieldConfig
     | RelationCustomFieldConfig
     | StringCustomFieldConfig
+    | StructCustomFieldConfig
     | TextCustomFieldConfig;
 
 export type Customer = Node & {
@@ -887,6 +899,24 @@ export type DateTimeCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+/**
+ * Expects the same validation formats as the `<input type="datetime-local">` HTML element.
+ * See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#Additional_attributes
+ */
+export type DateTimeStructFieldConfig = StructField & {
+    __typename?: 'DateTimeStructFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['String']['output']>;
+    min?: Maybe<Scalars['String']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type DeletionResponse = {
     __typename?: 'DeletionResponse';
     message?: Maybe<Scalars['String']['output']>;
@@ -1123,6 +1153,20 @@ export type FloatCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type FloatStructFieldConfig = StructField & {
+    __typename?: 'FloatStructFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Float']['output']>;
+    min?: Maybe<Scalars['Float']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Float']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type Fulfillment = Node & {
     __typename?: 'Fulfillment';
     createdAt: Scalars['DateTime']['output'];
@@ -1306,6 +1350,20 @@ export type IntCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type IntStructFieldConfig = StructField & {
+    __typename?: 'IntStructFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    max?: Maybe<Scalars['Int']['output']>;
+    min?: Maybe<Scalars['Int']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    step?: Maybe<Scalars['Int']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 /** Returned if the user authentication credentials are not valid */
 export type InvalidCredentialsError = ErrorResult & {
     __typename?: 'InvalidCredentialsError';
@@ -3307,6 +3365,53 @@ export type StringOperators = {
     regex?: InputMaybe<Scalars['String']['input']>;
 };
 
+export type StringStructFieldConfig = StructField & {
+    __typename?: 'StringStructFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    length?: Maybe<Scalars['Int']['output']>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    options?: Maybe<Array<StringFieldOption>>;
+    pattern?: Maybe<Scalars['String']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructCustomFieldConfig = CustomField & {
+    __typename?: 'StructCustomFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    fields: Array<StructFieldConfig>;
+    internal?: Maybe<Scalars['Boolean']['output']>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    readonly?: Maybe<Scalars['Boolean']['output']>;
+    requiresPermission?: Maybe<Array<Permission>>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructField = {
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list?: Maybe<Scalars['Boolean']['output']>;
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
+export type StructFieldConfig =
+    | BooleanStructFieldConfig
+    | DateTimeStructFieldConfig
+    | FloatStructFieldConfig
+    | IntStructFieldConfig
+    | StringStructFieldConfig
+    | TextStructFieldConfig;
+
 /** Indicates that an operation succeeded, where we do not want to return any more specific information. */
 export type Success = {
     __typename?: 'Success';
@@ -3390,6 +3495,17 @@ export type TextCustomFieldConfig = CustomField & {
     ui?: Maybe<Scalars['JSON']['output']>;
 };
 
+export type TextStructFieldConfig = StructField & {
+    __typename?: 'TextStructFieldConfig';
+    description?: Maybe<Array<LocalizedString>>;
+    label?: Maybe<Array<LocalizedString>>;
+    list: Scalars['Boolean']['output'];
+    name: Scalars['String']['output'];
+    nullable?: Maybe<Scalars['Boolean']['output']>;
+    type: Scalars['String']['output'];
+    ui?: Maybe<Scalars['JSON']['output']>;
+};
+
 export type TransitionOrderToStateResult = Order | OrderStateTransitionError;
 
 /**

文件差異過大導致無法顯示
+ 0 - 0
schema-admin.json


文件差異過大導致無法顯示
+ 0 - 0
schema-shop.json


+ 1 - 0
scripts/codegen/generate-graphql-types.ts

@@ -19,6 +19,7 @@ const specFileToIgnore = [
     'shop-definitions',
     'custom-fields.e2e-spec',
     'custom-field-relations.e2e-spec',
+    'custom-field-struct.e2e-spec',
     'custom-field-permissions.e2e-spec',
     'order-item-price-calculation-strategy.e2e-spec',
     'list-query-builder.e2e-spec',

部分文件因文件數量過多而無法顯示