Преглед изворни кода

feat(core): Add validation parameters to custom fields

Relates to #85
Michael Bromley пре 6 година
родитељ
комит
b6b13a5ef6

+ 72 - 2
packages/common/src/generated-shop-types.ts

@@ -103,6 +103,14 @@ export enum AssetType {
     BINARY = 'BINARY',
 }
 
+export type BooleanCustomFieldConfig = CustomField & {
+    __typename?: 'BooleanCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+};
+
 export type BooleanOperators = {
     eq?: Maybe<Scalars['Boolean']>;
 };
@@ -655,14 +663,22 @@ export type CustomerList = PaginatedList & {
     totalItems: Scalars['Int'];
 };
 
-export type CustomFieldConfig = {
-    __typename?: 'CustomFieldConfig';
+export type CustomField = {
+    __typename?: 'CustomField';
     name: Scalars['String'];
     type: Scalars['String'];
     label?: Maybe<Array<LocalizedString>>;
     description?: Maybe<Array<LocalizedString>>;
 };
 
+export type CustomFieldConfig =
+    | StringCustomFieldConfig
+    | LocaleStringCustomFieldConfig
+    | IntCustomFieldConfig
+    | FloatCustomFieldConfig
+    | BooleanCustomFieldConfig
+    | DateTimeCustomFieldConfig;
+
 export type CustomFields = {
     __typename?: 'CustomFields';
     Address: Array<CustomFieldConfig>;
@@ -691,6 +707,20 @@ export type DateRange = {
     end: Scalars['DateTime'];
 };
 
+/** 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 DateTimeCustomFieldConfig = CustomField & {
+    __typename?: 'DateTimeCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    min?: Maybe<Scalars['String']>;
+    max?: Maybe<Scalars['String']>;
+    step?: Maybe<Scalars['Int']>;
+};
+
 export type DeletionResponse = {
     __typename?: 'DeletionResponse';
     result: DeletionResult;
@@ -763,6 +793,17 @@ export type FacetValueTranslation = {
     name: Scalars['String'];
 };
 
+export type FloatCustomFieldConfig = CustomField & {
+    __typename?: 'FloatCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    min?: Maybe<Scalars['Float']>;
+    max?: Maybe<Scalars['Float']>;
+    step?: Maybe<Scalars['Float']>;
+};
+
 export type Fulfillment = Node & {
     __typename?: 'Fulfillment';
     id: Scalars['ID'];
@@ -835,6 +876,17 @@ export type ImportInfo = {
     imported: Scalars['Int'];
 };
 
+export type IntCustomFieldConfig = CustomField & {
+    __typename?: 'IntCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    min?: Maybe<Scalars['Int']>;
+    max?: Maybe<Scalars['Int']>;
+    step?: Maybe<Scalars['Int']>;
+};
+
 /** ISO 639-1 language code */
 export enum LanguageCode {
     /** Afar */
@@ -1207,6 +1259,15 @@ export enum LanguageCode {
     zu = 'zu',
 }
 
+export type LocaleStringCustomFieldConfig = CustomField & {
+    __typename?: 'LocaleStringCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    pattern?: Maybe<Scalars['String']>;
+};
+
 export type LocalizedString = {
     __typename?: 'LocalizedString';
     languageCode: LanguageCode;
@@ -2014,6 +2075,15 @@ export enum StockMovementType {
     RETURN = 'RETURN',
 }
 
+export type StringCustomFieldConfig = CustomField & {
+    __typename?: 'StringCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    pattern?: Maybe<Scalars['String']>;
+};
+
 export type StringOperators = {
     eq?: Maybe<Scalars['String']>;
     contains?: Maybe<Scalars['String']>;

+ 131 - 67
packages/common/src/generated-types.ts

@@ -161,6 +161,14 @@ export enum AssetType {
   BINARY = 'BINARY'
 }
 
+export type BooleanCustomFieldConfig = CustomField & {
+  __typename?: 'BooleanCustomFieldConfig',
+  name: Scalars['String'],
+  type: Scalars['String'],
+  label?: Maybe<Array<LocalizedString>>,
+  description?: Maybe<Array<LocalizedString>>,
+};
+
 export type BooleanOperators = {
   eq?: Maybe<Scalars['Boolean']>,
 };
@@ -932,14 +940,16 @@ export type CustomerSortParameter = {
   emailAddress?: Maybe<SortOrder>,
 };
 
-export type CustomFieldConfig = {
-  __typename?: 'CustomFieldConfig',
+export type CustomField = {
+  __typename?: 'CustomField',
   name: Scalars['String'],
   type: Scalars['String'],
   label?: Maybe<Array<LocalizedString>>,
   description?: Maybe<Array<LocalizedString>>,
 };
 
+export type CustomFieldConfig = StringCustomFieldConfig | LocaleStringCustomFieldConfig | IntCustomFieldConfig | FloatCustomFieldConfig | BooleanCustomFieldConfig | DateTimeCustomFieldConfig;
+
 export type CustomFields = {
   __typename?: 'CustomFields',
   Address: Array<CustomFieldConfig>,
@@ -969,6 +979,20 @@ export type DateRange = {
 };
 
 
+/** 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 DateTimeCustomFieldConfig = CustomField & {
+  __typename?: 'DateTimeCustomFieldConfig',
+  name: Scalars['String'],
+  type: Scalars['String'],
+  label?: Maybe<Array<LocalizedString>>,
+  description?: Maybe<Array<LocalizedString>>,
+  min?: Maybe<Scalars['String']>,
+  max?: Maybe<Scalars['String']>,
+  step?: Maybe<Scalars['Int']>,
+};
+
 export type DeletionResponse = {
   __typename?: 'DeletionResponse',
   result: DeletionResult,
@@ -1080,6 +1104,17 @@ export type FacetValueTranslationInput = {
   customFields?: Maybe<Scalars['JSON']>,
 };
 
+export type FloatCustomFieldConfig = CustomField & {
+  __typename?: 'FloatCustomFieldConfig',
+  name: Scalars['String'],
+  type: Scalars['String'],
+  label?: Maybe<Array<LocalizedString>>,
+  description?: Maybe<Array<LocalizedString>>,
+  min?: Maybe<Scalars['Float']>,
+  max?: Maybe<Scalars['Float']>,
+  step?: Maybe<Scalars['Float']>,
+};
+
 export type Fulfillment = Node & {
   __typename?: 'Fulfillment',
   id: Scalars['ID'],
@@ -1158,6 +1193,17 @@ export type ImportInfo = {
   imported: Scalars['Int'],
 };
 
+export type IntCustomFieldConfig = CustomField & {
+  __typename?: 'IntCustomFieldConfig',
+  name: Scalars['String'],
+  type: Scalars['String'],
+  label?: Maybe<Array<LocalizedString>>,
+  description?: Maybe<Array<LocalizedString>>,
+  min?: Maybe<Scalars['Int']>,
+  max?: Maybe<Scalars['Int']>,
+  step?: Maybe<Scalars['Int']>,
+};
+
 export type JobInfo = {
   __typename?: 'JobInfo',
   id: Scalars['String'],
@@ -1555,6 +1601,15 @@ export enum LanguageCode {
   zu = 'zu'
 }
 
+export type LocaleStringCustomFieldConfig = CustomField & {
+  __typename?: 'LocaleStringCustomFieldConfig',
+  name: Scalars['String'],
+  type: Scalars['String'],
+  label?: Maybe<Array<LocalizedString>>,
+  description?: Maybe<Array<LocalizedString>>,
+  pattern?: Maybe<Scalars['String']>,
+};
+
 export type LocalizedString = {
   __typename?: 'LocalizedString',
   languageCode: LanguageCode,
@@ -1574,14 +1629,14 @@ export type MoveCollectionInput = {
 
 export type Mutation = {
   __typename?: 'Mutation',
-  /** Create a new Asset */
-  createAssets: Array<Asset>,
   /** Create a new Administrator */
   createAdministrator: Administrator,
   /** Update an existing Administrator */
   updateAdministrator: Administrator,
   /** Assign a Role to an Administrator */
   assignRoleToAdministrator: Administrator,
+  /** Create a new Asset */
+  createAssets: Array<Asset>,
   login: LoginResult,
   logout: Scalars['Boolean'],
   /** Create a new Channel */
@@ -1642,8 +1697,6 @@ export type Mutation = {
   refundOrder: Refund,
   settleRefund: Refund,
   addNoteToOrder: Order,
-  /** Update an existing PaymentMethod */
-  updatePaymentMethod: PaymentMethod,
   /** Create a new ProductOptionGroup */
   createProductOptionGroup: ProductOptionGroup,
   /** Update an existing ProductOptionGroup */
@@ -1652,7 +1705,8 @@ export type Mutation = {
   createProductOption: ProductOption,
   /** Create a new ProductOption within a ProductOptionGroup */
   updateProductOption: ProductOption,
-  reindex: JobInfo,
+  /** Update an existing PaymentMethod */
+  updatePaymentMethod: PaymentMethod,
   /** Create a new Product */
   createProduct: Product,
   /** Update an existing Product */
@@ -1669,6 +1723,7 @@ export type Mutation = {
   updateProductVariants: Array<Maybe<ProductVariant>>,
   /** Delete a ProductVariant */
   deleteProductVariant: DeletionResponse,
+  reindex: JobInfo,
   createPromotion: Promotion,
   updatePromotion: Promotion,
   deletePromotion: DeletionResponse,
@@ -1684,6 +1739,10 @@ export type Mutation = {
   createTaxCategory: TaxCategory,
   /** Update an existing TaxCategory */
   updateTaxCategory: TaxCategory,
+  /** Create a new TaxRate */
+  createTaxRate: TaxRate,
+  /** Update an existing TaxRate */
+  updateTaxRate: TaxRate,
   /** Create a new Zone */
   createZone: Zone,
   /** Update an existing Zone */
@@ -1694,15 +1753,6 @@ export type Mutation = {
   addMembersToZone: Zone,
   /** Remove members from a Zone */
   removeMembersFromZone: Zone,
-  /** Create a new TaxRate */
-  createTaxRate: TaxRate,
-  /** Update an existing TaxRate */
-  updateTaxRate: TaxRate,
-};
-
-
-export type MutationCreateAssetsArgs = {
-  input: Array<CreateAssetInput>
 };
 
 
@@ -1722,6 +1772,11 @@ export type MutationAssignRoleToAdministratorArgs = {
 };
 
 
+export type MutationCreateAssetsArgs = {
+  input: Array<CreateAssetInput>
+};
+
+
 export type MutationLoginArgs = {
   username: Scalars['String'],
   password: Scalars['String'],
@@ -1900,11 +1955,6 @@ export type MutationAddNoteToOrderArgs = {
 };
 
 
-export type MutationUpdatePaymentMethodArgs = {
-  input: UpdatePaymentMethodInput
-};
-
-
 export type MutationCreateProductOptionGroupArgs = {
   input: CreateProductOptionGroupInput
 };
@@ -1925,6 +1975,11 @@ export type MutationUpdateProductOptionArgs = {
 };
 
 
+export type MutationUpdatePaymentMethodArgs = {
+  input: UpdatePaymentMethodInput
+};
+
+
 export type MutationCreateProductArgs = {
   input: CreateProductInput
 };
@@ -2012,6 +2067,16 @@ export type MutationUpdateTaxCategoryArgs = {
 };
 
 
+export type MutationCreateTaxRateArgs = {
+  input: CreateTaxRateInput
+};
+
+
+export type MutationUpdateTaxRateArgs = {
+  input: UpdateTaxRateInput
+};
+
+
 export type MutationCreateZoneArgs = {
   input: CreateZoneInput
 };
@@ -2038,16 +2103,6 @@ export type MutationRemoveMembersFromZoneArgs = {
   memberIds: Array<Scalars['ID']>
 };
 
-
-export type MutationCreateTaxRateArgs = {
-  input: CreateTaxRateInput
-};
-
-
-export type MutationUpdateTaxRateArgs = {
-  input: UpdateTaxRateInput
-};
-
 export type Node = {
   __typename?: 'Node',
   id: Scalars['ID'],
@@ -2544,10 +2599,10 @@ export type PromotionSortParameter = {
 
 export type Query = {
   __typename?: 'Query',
-  assets: AssetList,
-  asset?: Maybe<Asset>,
   administrators: AdministratorList,
   administrator?: Maybe<Administrator>,
+  assets: AssetList,
+  asset?: Maybe<Asset>,
   me?: Maybe<CurrentUser>,
   channels: Array<Channel>,
   channel?: Maybe<Channel>,
@@ -2564,18 +2619,18 @@ export type Query = {
   facets: FacetList,
   facet?: Maybe<Facet>,
   globalSettings: GlobalSettings,
-  job?: Maybe<JobInfo>,
-  jobs: Array<JobInfo>,
   order?: Maybe<Order>,
   orders: OrderList,
-  paymentMethods: PaymentMethodList,
-  paymentMethod?: Maybe<PaymentMethod>,
+  job?: Maybe<JobInfo>,
+  jobs: Array<JobInfo>,
   productOptionGroups: Array<ProductOptionGroup>,
   productOptionGroup?: Maybe<ProductOptionGroup>,
-  search: SearchResponse,
+  paymentMethods: PaymentMethodList,
+  paymentMethod?: Maybe<PaymentMethod>,
   products: ProductList,
   /** Get a Product either by id or slug. If neither id nor slug is speicified, an error will result. */
   product?: Maybe<Product>,
+  search: SearchResponse,
   promotion?: Maybe<Promotion>,
   promotions: PromotionList,
   adjustmentOperations: AdjustmentOperations,
@@ -2587,29 +2642,29 @@ export type Query = {
   shippingCalculators: Array<ConfigurableOperation>,
   taxCategories: Array<TaxCategory>,
   taxCategory?: Maybe<TaxCategory>,
-  zones: Array<Zone>,
-  zone?: Maybe<Zone>,
   taxRates: TaxRateList,
   taxRate?: Maybe<TaxRate>,
+  zones: Array<Zone>,
+  zone?: Maybe<Zone>,
 };
 
 
-export type QueryAssetsArgs = {
-  options?: Maybe<AssetListOptions>
+export type QueryAdministratorsArgs = {
+  options?: Maybe<AdministratorListOptions>
 };
 
 
-export type QueryAssetArgs = {
+export type QueryAdministratorArgs = {
   id: Scalars['ID']
 };
 
 
-export type QueryAdministratorsArgs = {
-  options?: Maybe<AdministratorListOptions>
+export type QueryAssetsArgs = {
+  options?: Maybe<AssetListOptions>
 };
 
 
-export type QueryAdministratorArgs = {
+export type QueryAssetArgs = {
   id: Scalars['ID']
 };
 
@@ -2668,16 +2723,6 @@ export type QueryFacetArgs = {
 };
 
 
-export type QueryJobArgs = {
-  jobId: Scalars['String']
-};
-
-
-export type QueryJobsArgs = {
-  input?: Maybe<JobListInput>
-};
-
-
 export type QueryOrderArgs = {
   id: Scalars['ID']
 };
@@ -2688,13 +2733,13 @@ export type QueryOrdersArgs = {
 };
 
 
-export type QueryPaymentMethodsArgs = {
-  options?: Maybe<PaymentMethodListOptions>
+export type QueryJobArgs = {
+  jobId: Scalars['String']
 };
 
 
-export type QueryPaymentMethodArgs = {
-  id: Scalars['ID']
+export type QueryJobsArgs = {
+  input?: Maybe<JobListInput>
 };
 
 
@@ -2710,8 +2755,13 @@ export type QueryProductOptionGroupArgs = {
 };
 
 
-export type QuerySearchArgs = {
-  input: SearchInput
+export type QueryPaymentMethodsArgs = {
+  options?: Maybe<PaymentMethodListOptions>
+};
+
+
+export type QueryPaymentMethodArgs = {
+  id: Scalars['ID']
 };
 
 
@@ -2728,6 +2778,11 @@ export type QueryProductArgs = {
 };
 
 
+export type QuerySearchArgs = {
+  input: SearchInput
+};
+
+
 export type QueryPromotionArgs = {
   id: Scalars['ID']
 };
@@ -2763,11 +2818,6 @@ export type QueryTaxCategoryArgs = {
 };
 
 
-export type QueryZoneArgs = {
-  id: Scalars['ID']
-};
-
-
 export type QueryTaxRatesArgs = {
   options?: Maybe<TaxRateListOptions>
 };
@@ -2777,6 +2827,11 @@ export type QueryTaxRateArgs = {
   id: Scalars['ID']
 };
 
+
+export type QueryZoneArgs = {
+  id: Scalars['ID']
+};
+
 export type Refund = Node & {
   __typename?: 'Refund',
   id: Scalars['ID'],
@@ -3028,6 +3083,15 @@ export enum StockMovementType {
   RETURN = 'RETURN'
 }
 
+export type StringCustomFieldConfig = CustomField & {
+  __typename?: 'StringCustomFieldConfig',
+  name: Scalars['String'],
+  type: Scalars['String'],
+  label?: Maybe<Array<LocalizedString>>,
+  description?: Maybe<Array<LocalizedString>>,
+  pattern?: Maybe<Scalars['String']>,
+};
+
 export type StringOperators = {
   eq?: Maybe<Scalars['String']>,
   contains?: Maybe<Scalars['String']>,

+ 139 - 32
packages/core/e2e/custom-fields.e2e-spec.ts

@@ -1,3 +1,6 @@
+// Force the timezone to avoid tests failing in other locales
+process.env.TZ = 'UTC';
+
 import gql from 'graphql-tag';
 import path from 'path';
 
@@ -14,23 +17,40 @@ describe('Custom fields', () => {
     const server = new TestServer();
 
     beforeAll(async () => {
-        await server.init({
-            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-            customerCount: 1,
-        }, {
-            customFields: {
-                Product: [
-                    { name: 'nullable', type: 'string' },
-                    { name: 'notNullable', type: 'string', nullable: false, defaultValue: '' },
-                    { name: 'stringWithDefault', type: 'string', defaultValue: 'hello' },
-                    { name: 'localeStringWithDefault', type: 'localeString', defaultValue: 'hola' },
-                    { name: 'intWithDefault', type: 'int', defaultValue: 5 },
-                    { name: 'floatWithDefault', type: 'float', defaultValue: 5.5 },
-                    { name: 'booleanWithDefault', type: 'boolean', defaultValue: true },
-                    { name: 'dateTimeWithDefault', type: 'datetime', defaultValue: new Date('2019-04-30T12:59:16.4158386+02:00') },
-                ],
+        await server.init(
+            {
+                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+                customerCount: 1,
             },
-        });
+            {
+                customFields: {
+                    Product: [
+                        { name: 'nullable', type: 'string' },
+                        { name: 'notNullable', type: 'string', nullable: false, defaultValue: '' },
+                        { name: 'stringWithDefault', type: 'string', defaultValue: 'hello' },
+                        { name: 'localeStringWithDefault', type: 'localeString', defaultValue: 'hola' },
+                        { name: 'intWithDefault', type: 'int', defaultValue: 5 },
+                        { name: 'floatWithDefault', type: 'float', defaultValue: 5.5 },
+                        { name: 'booleanWithDefault', type: 'boolean', defaultValue: true },
+                        {
+                            name: 'dateTimeWithDefault',
+                            type: 'datetime',
+                            defaultValue: new Date('2019-04-30T12:59:16.4158386Z'),
+                        },
+                        { name: 'validateString', type: 'string', pattern: '^[0-9][a-z]+$' },
+                        { name: 'validateLocaleString', type: 'localeString', pattern: '^[0-9][a-z]+$' },
+                        { name: 'validateInt', type: 'int', min: 0, max: 10 },
+                        { name: 'validateFloat', type: 'float', min: 0.5, max: 10.5 },
+                        {
+                            name: 'validateDateTime',
+                            type: 'datetime',
+                            min: '2019-01-01T08:30',
+                            max: '2019-06-01T08:30',
+                        },
+                    ],
+                },
+            },
+        );
         await adminClient.init();
         await shopClient.init();
     }, TEST_SETUP_TIMEOUT_MS);
@@ -46,8 +66,10 @@ describe('Custom fields', () => {
                     serverConfig {
                         customFieldConfig {
                             Product {
-                                name
-                                type
+                                ... on CustomField {
+                                    name
+                                    type
+                                }
                             }
                         }
                     }
@@ -65,6 +87,11 @@ describe('Custom fields', () => {
                 { name: 'floatWithDefault', type: 'float' },
                 { name: 'booleanWithDefault', type: 'boolean' },
                 { name: 'dateTimeWithDefault', type: 'datetime' },
+                { name: 'validateString', type: 'string' },
+                { name: 'validateLocaleString', type: 'localeString' },
+                { name: 'validateInt', type: 'int' },
+                { name: 'validateFloat', type: 'float' },
+                { name: 'validateDateTime', type: 'datetime' },
             ],
         });
     });
@@ -79,8 +106,8 @@ describe('Custom fields', () => {
                         nullable
                     }
                 }
-            }`,
-        );
+            }
+        `);
 
         expect(product).toEqual({
             id: 'T_1',
@@ -106,8 +133,8 @@ describe('Custom fields', () => {
                         dateTimeWithDefault
                     }
                 }
-            }`,
-        );
+            }
+        `);
 
         expect(product).toEqual({
             id: 'T_1',
@@ -123,19 +150,99 @@ describe('Custom fields', () => {
         });
     });
 
-    it('update non-nullable field', assertThrowsWithMessage(async () => {
+    it(
+        'update non-nullable field',
+        assertThrowsWithMessage(async () => {
             await adminClient.query(gql`
                 mutation {
-                    updateProduct(input: {
-                        id: "T_1"
-                        customFields: {
-                            notNullable: null
-                        }
-                    }) { id }
+                    updateProduct(input: { id: "T_1", customFields: { notNullable: null } }) {
+                        id
+                    }
                 }
             `);
-        },
-        'NOT NULL constraint failed: product.customFieldsNotnullable',
-        ),
+        }, 'NOT NULL constraint failed: product.customFieldsNotnullable'),
     );
+
+    describe('validation', () => {
+        it(
+            'invalid string',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query(gql`
+                    mutation {
+                        updateProduct(input: { id: "T_1", customFields: { validateString: "hello" } }) {
+                            id
+                        }
+                    }
+                `);
+            }, `The custom field value ['hello'] does not match the pattern [^[0-9][a-z]+$]`),
+        );
+
+        it(
+            'invalid localeString',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query(gql`
+                    mutation {
+                        updateProduct(input: {
+                            id: "T_1"
+                            translations: [{
+                                id: "T_1"
+                                languageCode: en,
+                                customFields: { validateLocaleString: "servus" }
+                            }]
+                        }) {
+                            id
+                        }
+                    }
+                `);
+            }, `The custom field value ['servus'] does not match the pattern [^[0-9][a-z]+$]`),
+        );
+
+        it(
+            'invalid int',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query(gql`
+                    mutation {
+                        updateProduct(input: {
+                            id: "T_1"
+                            customFields: { validateInt: 12 }
+                        }) {
+                            id
+                        }
+                    }
+                `);
+            }, `The custom field value [12] is greater than the maximum [10]`),
+        );
+
+        it(
+            'invalid float',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query(gql`
+                    mutation {
+                        updateProduct(input: {
+                            id: "T_1"
+                            customFields: { validateFloat: 10.6 }
+                        }) {
+                            id
+                        }
+                    }
+                `);
+            }, `The custom field value [10.6] is greater than the maximum [10.5]`),
+        );
+
+        it(
+            'invalid datetime',
+            assertThrowsWithMessage(async () => {
+                await adminClient.query(gql`
+                    mutation {
+                        updateProduct(input: {
+                            id: "T_1"
+                            customFields: { validateDateTime: "2019-01-01T05:25:00.000Z" }
+                        }) {
+                            id
+                        }
+                    }
+                `);
+            }, `The custom field value [2019-01-01T05:25:00.000Z] is less than the minimum [2019-01-01T08:30]`),
+        );
+    });
 });

+ 130 - 60
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -161,6 +161,14 @@ export enum AssetType {
     BINARY = 'BINARY',
 }
 
+export type BooleanCustomFieldConfig = CustomField & {
+    __typename?: 'BooleanCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+};
+
 export type BooleanOperators = {
     eq?: Maybe<Scalars['Boolean']>;
 };
@@ -931,14 +939,22 @@ export type CustomerSortParameter = {
     emailAddress?: Maybe<SortOrder>;
 };
 
-export type CustomFieldConfig = {
-    __typename?: 'CustomFieldConfig';
+export type CustomField = {
+    __typename?: 'CustomField';
     name: Scalars['String'];
     type: Scalars['String'];
     label?: Maybe<Array<LocalizedString>>;
     description?: Maybe<Array<LocalizedString>>;
 };
 
+export type CustomFieldConfig =
+    | StringCustomFieldConfig
+    | LocaleStringCustomFieldConfig
+    | IntCustomFieldConfig
+    | FloatCustomFieldConfig
+    | BooleanCustomFieldConfig
+    | DateTimeCustomFieldConfig;
+
 export type CustomFields = {
     __typename?: 'CustomFields';
     Address: Array<CustomFieldConfig>;
@@ -967,6 +983,20 @@ export type DateRange = {
     end: Scalars['DateTime'];
 };
 
+/** 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 DateTimeCustomFieldConfig = CustomField & {
+    __typename?: 'DateTimeCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    min?: Maybe<Scalars['String']>;
+    max?: Maybe<Scalars['String']>;
+    step?: Maybe<Scalars['Int']>;
+};
+
 export type DeletionResponse = {
     __typename?: 'DeletionResponse';
     result: DeletionResult;
@@ -1078,6 +1108,17 @@ export type FacetValueTranslationInput = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type FloatCustomFieldConfig = CustomField & {
+    __typename?: 'FloatCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    min?: Maybe<Scalars['Float']>;
+    max?: Maybe<Scalars['Float']>;
+    step?: Maybe<Scalars['Float']>;
+};
+
 export type Fulfillment = Node & {
     __typename?: 'Fulfillment';
     id: Scalars['ID'];
@@ -1156,6 +1197,17 @@ export type ImportInfo = {
     imported: Scalars['Int'];
 };
 
+export type IntCustomFieldConfig = CustomField & {
+    __typename?: 'IntCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    min?: Maybe<Scalars['Int']>;
+    max?: Maybe<Scalars['Int']>;
+    step?: Maybe<Scalars['Int']>;
+};
+
 export type JobInfo = {
     __typename?: 'JobInfo';
     id: Scalars['String'];
@@ -1552,6 +1604,15 @@ export enum LanguageCode {
     zu = 'zu',
 }
 
+export type LocaleStringCustomFieldConfig = CustomField & {
+    __typename?: 'LocaleStringCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    pattern?: Maybe<Scalars['String']>;
+};
+
 export type LocalizedString = {
     __typename?: 'LocalizedString';
     languageCode: LanguageCode;
@@ -1571,14 +1632,14 @@ export type MoveCollectionInput = {
 
 export type Mutation = {
     __typename?: 'Mutation';
-    /** Create a new Asset */
-    createAssets: Array<Asset>;
     /** Create a new Administrator */
     createAdministrator: Administrator;
     /** Update an existing Administrator */
     updateAdministrator: Administrator;
     /** Assign a Role to an Administrator */
     assignRoleToAdministrator: Administrator;
+    /** Create a new Asset */
+    createAssets: Array<Asset>;
     login: LoginResult;
     logout: Scalars['Boolean'];
     /** Create a new Channel */
@@ -1639,8 +1700,6 @@ export type Mutation = {
     refundOrder: Refund;
     settleRefund: Refund;
     addNoteToOrder: Order;
-    /** Update an existing PaymentMethod */
-    updatePaymentMethod: PaymentMethod;
     /** Create a new ProductOptionGroup */
     createProductOptionGroup: ProductOptionGroup;
     /** Update an existing ProductOptionGroup */
@@ -1649,7 +1708,8 @@ export type Mutation = {
     createProductOption: ProductOption;
     /** Create a new ProductOption within a ProductOptionGroup */
     updateProductOption: ProductOption;
-    reindex: JobInfo;
+    /** Update an existing PaymentMethod */
+    updatePaymentMethod: PaymentMethod;
     /** Create a new Product */
     createProduct: Product;
     /** Update an existing Product */
@@ -1666,6 +1726,7 @@ export type Mutation = {
     updateProductVariants: Array<Maybe<ProductVariant>>;
     /** Delete a ProductVariant */
     deleteProductVariant: DeletionResponse;
+    reindex: JobInfo;
     createPromotion: Promotion;
     updatePromotion: Promotion;
     deletePromotion: DeletionResponse;
@@ -1681,6 +1742,10 @@ export type Mutation = {
     createTaxCategory: TaxCategory;
     /** Update an existing TaxCategory */
     updateTaxCategory: TaxCategory;
+    /** Create a new TaxRate */
+    createTaxRate: TaxRate;
+    /** Update an existing TaxRate */
+    updateTaxRate: TaxRate;
     /** Create a new Zone */
     createZone: Zone;
     /** Update an existing Zone */
@@ -1691,14 +1756,6 @@ export type Mutation = {
     addMembersToZone: Zone;
     /** Remove members from a Zone */
     removeMembersFromZone: Zone;
-    /** Create a new TaxRate */
-    createTaxRate: TaxRate;
-    /** Update an existing TaxRate */
-    updateTaxRate: TaxRate;
-};
-
-export type MutationCreateAssetsArgs = {
-    input: Array<CreateAssetInput>;
 };
 
 export type MutationCreateAdministratorArgs = {
@@ -1714,6 +1771,10 @@ export type MutationAssignRoleToAdministratorArgs = {
     roleId: Scalars['ID'];
 };
 
+export type MutationCreateAssetsArgs = {
+    input: Array<CreateAssetInput>;
+};
+
 export type MutationLoginArgs = {
     username: Scalars['String'];
     password: Scalars['String'];
@@ -1858,10 +1919,6 @@ export type MutationAddNoteToOrderArgs = {
     input: AddNoteToOrderInput;
 };
 
-export type MutationUpdatePaymentMethodArgs = {
-    input: UpdatePaymentMethodInput;
-};
-
 export type MutationCreateProductOptionGroupArgs = {
     input: CreateProductOptionGroupInput;
 };
@@ -1878,6 +1935,10 @@ export type MutationUpdateProductOptionArgs = {
     input: UpdateProductOptionInput;
 };
 
+export type MutationUpdatePaymentMethodArgs = {
+    input: UpdatePaymentMethodInput;
+};
+
 export type MutationCreateProductArgs = {
     input: CreateProductInput;
 };
@@ -1948,6 +2009,14 @@ export type MutationUpdateTaxCategoryArgs = {
     input: UpdateTaxCategoryInput;
 };
 
+export type MutationCreateTaxRateArgs = {
+    input: CreateTaxRateInput;
+};
+
+export type MutationUpdateTaxRateArgs = {
+    input: UpdateTaxRateInput;
+};
+
 export type MutationCreateZoneArgs = {
     input: CreateZoneInput;
 };
@@ -1970,14 +2039,6 @@ export type MutationRemoveMembersFromZoneArgs = {
     memberIds: Array<Scalars['ID']>;
 };
 
-export type MutationCreateTaxRateArgs = {
-    input: CreateTaxRateInput;
-};
-
-export type MutationUpdateTaxRateArgs = {
-    input: UpdateTaxRateInput;
-};
-
 export type Node = {
     __typename?: 'Node';
     id: Scalars['ID'];
@@ -2472,10 +2533,10 @@ export type PromotionSortParameter = {
 
 export type Query = {
     __typename?: 'Query';
-    assets: AssetList;
-    asset?: Maybe<Asset>;
     administrators: AdministratorList;
     administrator?: Maybe<Administrator>;
+    assets: AssetList;
+    asset?: Maybe<Asset>;
     me?: Maybe<CurrentUser>;
     channels: Array<Channel>;
     channel?: Maybe<Channel>;
@@ -2492,18 +2553,18 @@ export type Query = {
     facets: FacetList;
     facet?: Maybe<Facet>;
     globalSettings: GlobalSettings;
-    job?: Maybe<JobInfo>;
-    jobs: Array<JobInfo>;
     order?: Maybe<Order>;
     orders: OrderList;
-    paymentMethods: PaymentMethodList;
-    paymentMethod?: Maybe<PaymentMethod>;
+    job?: Maybe<JobInfo>;
+    jobs: Array<JobInfo>;
     productOptionGroups: Array<ProductOptionGroup>;
     productOptionGroup?: Maybe<ProductOptionGroup>;
-    search: SearchResponse;
+    paymentMethods: PaymentMethodList;
+    paymentMethod?: Maybe<PaymentMethod>;
     products: ProductList;
     /** Get a Product either by id or slug. If neither id nor slug is speicified, an error will result. */
     product?: Maybe<Product>;
+    search: SearchResponse;
     promotion?: Maybe<Promotion>;
     promotions: PromotionList;
     adjustmentOperations: AdjustmentOperations;
@@ -2515,25 +2576,25 @@ export type Query = {
     shippingCalculators: Array<ConfigurableOperation>;
     taxCategories: Array<TaxCategory>;
     taxCategory?: Maybe<TaxCategory>;
-    zones: Array<Zone>;
-    zone?: Maybe<Zone>;
     taxRates: TaxRateList;
     taxRate?: Maybe<TaxRate>;
+    zones: Array<Zone>;
+    zone?: Maybe<Zone>;
 };
 
-export type QueryAssetsArgs = {
-    options?: Maybe<AssetListOptions>;
+export type QueryAdministratorsArgs = {
+    options?: Maybe<AdministratorListOptions>;
 };
 
-export type QueryAssetArgs = {
+export type QueryAdministratorArgs = {
     id: Scalars['ID'];
 };
 
-export type QueryAdministratorsArgs = {
-    options?: Maybe<AdministratorListOptions>;
+export type QueryAssetsArgs = {
+    options?: Maybe<AssetListOptions>;
 };
 
-export type QueryAdministratorArgs = {
+export type QueryAssetArgs = {
     id: Scalars['ID'];
 };
 
@@ -2581,14 +2642,6 @@ export type QueryFacetArgs = {
     languageCode?: Maybe<LanguageCode>;
 };
 
-export type QueryJobArgs = {
-    jobId: Scalars['String'];
-};
-
-export type QueryJobsArgs = {
-    input?: Maybe<JobListInput>;
-};
-
 export type QueryOrderArgs = {
     id: Scalars['ID'];
 };
@@ -2597,12 +2650,12 @@ export type QueryOrdersArgs = {
     options?: Maybe<OrderListOptions>;
 };
 
-export type QueryPaymentMethodsArgs = {
-    options?: Maybe<PaymentMethodListOptions>;
+export type QueryJobArgs = {
+    jobId: Scalars['String'];
 };
 
-export type QueryPaymentMethodArgs = {
-    id: Scalars['ID'];
+export type QueryJobsArgs = {
+    input?: Maybe<JobListInput>;
 };
 
 export type QueryProductOptionGroupsArgs = {
@@ -2615,8 +2668,12 @@ export type QueryProductOptionGroupArgs = {
     languageCode?: Maybe<LanguageCode>;
 };
 
-export type QuerySearchArgs = {
-    input: SearchInput;
+export type QueryPaymentMethodsArgs = {
+    options?: Maybe<PaymentMethodListOptions>;
+};
+
+export type QueryPaymentMethodArgs = {
+    id: Scalars['ID'];
 };
 
 export type QueryProductsArgs = {
@@ -2630,6 +2687,10 @@ export type QueryProductArgs = {
     languageCode?: Maybe<LanguageCode>;
 };
 
+export type QuerySearchArgs = {
+    input: SearchInput;
+};
+
 export type QueryPromotionArgs = {
     id: Scalars['ID'];
 };
@@ -2658,10 +2719,6 @@ export type QueryTaxCategoryArgs = {
     id: Scalars['ID'];
 };
 
-export type QueryZoneArgs = {
-    id: Scalars['ID'];
-};
-
 export type QueryTaxRatesArgs = {
     options?: Maybe<TaxRateListOptions>;
 };
@@ -2670,6 +2727,10 @@ export type QueryTaxRateArgs = {
     id: Scalars['ID'];
 };
 
+export type QueryZoneArgs = {
+    id: Scalars['ID'];
+};
+
 export type Refund = Node & {
     __typename?: 'Refund';
     id: Scalars['ID'];
@@ -2924,6 +2985,15 @@ export enum StockMovementType {
     RETURN = 'RETURN',
 }
 
+export type StringCustomFieldConfig = CustomField & {
+    __typename?: 'StringCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    pattern?: Maybe<Scalars['String']>;
+};
+
 export type StringOperators = {
     eq?: Maybe<Scalars['String']>;
     contains?: Maybe<Scalars['String']>;

+ 72 - 2
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -103,6 +103,14 @@ export enum AssetType {
     BINARY = 'BINARY',
 }
 
+export type BooleanCustomFieldConfig = CustomField & {
+    __typename?: 'BooleanCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+};
+
 export type BooleanOperators = {
     eq?: Maybe<Scalars['Boolean']>;
 };
@@ -655,14 +663,22 @@ export type CustomerList = PaginatedList & {
     totalItems: Scalars['Int'];
 };
 
-export type CustomFieldConfig = {
-    __typename?: 'CustomFieldConfig';
+export type CustomField = {
+    __typename?: 'CustomField';
     name: Scalars['String'];
     type: Scalars['String'];
     label?: Maybe<Array<LocalizedString>>;
     description?: Maybe<Array<LocalizedString>>;
 };
 
+export type CustomFieldConfig =
+    | StringCustomFieldConfig
+    | LocaleStringCustomFieldConfig
+    | IntCustomFieldConfig
+    | FloatCustomFieldConfig
+    | BooleanCustomFieldConfig
+    | DateTimeCustomFieldConfig;
+
 export type CustomFields = {
     __typename?: 'CustomFields';
     Address: Array<CustomFieldConfig>;
@@ -691,6 +707,20 @@ export type DateRange = {
     end: Scalars['DateTime'];
 };
 
+/** 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 DateTimeCustomFieldConfig = CustomField & {
+    __typename?: 'DateTimeCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    min?: Maybe<Scalars['String']>;
+    max?: Maybe<Scalars['String']>;
+    step?: Maybe<Scalars['Int']>;
+};
+
 export type DeletionResponse = {
     __typename?: 'DeletionResponse';
     result: DeletionResult;
@@ -763,6 +793,17 @@ export type FacetValueTranslation = {
     name: Scalars['String'];
 };
 
+export type FloatCustomFieldConfig = CustomField & {
+    __typename?: 'FloatCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    min?: Maybe<Scalars['Float']>;
+    max?: Maybe<Scalars['Float']>;
+    step?: Maybe<Scalars['Float']>;
+};
+
 export type Fulfillment = Node & {
     __typename?: 'Fulfillment';
     id: Scalars['ID'];
@@ -835,6 +876,17 @@ export type ImportInfo = {
     imported: Scalars['Int'];
 };
 
+export type IntCustomFieldConfig = CustomField & {
+    __typename?: 'IntCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    min?: Maybe<Scalars['Int']>;
+    max?: Maybe<Scalars['Int']>;
+    step?: Maybe<Scalars['Int']>;
+};
+
 /** ISO 639-1 language code */
 export enum LanguageCode {
     /** Afar */
@@ -1207,6 +1259,15 @@ export enum LanguageCode {
     zu = 'zu',
 }
 
+export type LocaleStringCustomFieldConfig = CustomField & {
+    __typename?: 'LocaleStringCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    pattern?: Maybe<Scalars['String']>;
+};
+
 export type LocalizedString = {
     __typename?: 'LocalizedString';
     languageCode: LanguageCode;
@@ -2014,6 +2075,15 @@ export enum StockMovementType {
     RETURN = 'RETURN',
 }
 
+export type StringCustomFieldConfig = CustomField & {
+    __typename?: 'StringCustomFieldConfig';
+    name: Scalars['String'];
+    type: Scalars['String'];
+    label?: Maybe<Array<LocalizedString>>;
+    description?: Maybe<Array<LocalizedString>>;
+    pattern?: Maybe<Scalars['String']>;
+};
+
 export type StringOperators = {
     eq?: Maybe<Scalars['String']>;
     contains?: Maybe<Scalars['String']>;

+ 5 - 0
packages/core/src/api/api.module.ts

@@ -11,6 +11,7 @@ import { configureGraphQLModule } from './config/configure-graphql-module';
 import { AssetInterceptor } from './middleware/asset-interceptor';
 import { AuthGuard } from './middleware/auth-guard';
 import { IdInterceptor } from './middleware/id-interceptor';
+import { ValidateCustomFieldsInterceptor } from './middleware/validate-custom-fields-interceptor';
 
 /**
  * The ApiModule is responsible for the public API of the application. This is where requests
@@ -55,6 +56,10 @@ import { IdInterceptor } from './middleware/id-interceptor';
             provide: APP_INTERCEPTOR,
             useClass: IdInterceptor,
         },
+        {
+            provide: APP_INTERCEPTOR,
+            useClass: ValidateCustomFieldsInterceptor,
+        },
     ],
 })
 export class ApiModule {}

+ 68 - 0
packages/core/src/api/common/validate-custom-field-value.spec.ts

@@ -0,0 +1,68 @@
+import { validateCustomFieldValue } from './validate-custom-field-value';
+
+describe('validateCustomFieldValue()', () => {
+
+    describe('string & localeString', () => {
+
+        const validate = (value: string) => () => validateCustomFieldValue({
+            name: 'test',
+            type: 'string',
+            pattern: '^[0-9]+',
+        }, value);
+
+        it('passes valid pattern', () => {
+            expect(validate('1')).not.toThrow();
+            expect(validate('123')).not.toThrow();
+            expect(validate('1foo')).not.toThrow();
+        });
+
+        it('throws on invalid pattern', () => {
+            expect(validate('')).toThrowError('error.field-invalid-string-pattern');
+            expect(validate('foo')).toThrowError('error.field-invalid-string-pattern');
+            expect(validate(' 1foo')).toThrowError('error.field-invalid-string-pattern');
+        });
+    });
+
+    describe('int & float', () => {
+
+        const validate = (value: number) => () => validateCustomFieldValue({
+            name: 'test',
+            type: 'int',
+            min: 5,
+            max: 10,
+        }, value);
+
+        it('passes valid range', () => {
+            expect(validate(5)).not.toThrow();
+            expect(validate(7)).not.toThrow();
+            expect(validate(10)).not.toThrow();
+        });
+
+        it('throws on invalid range', () => {
+            expect(validate(4)).toThrowError('error.field-invalid-number-range-min');
+            expect(validate(11)).toThrowError('error.field-invalid-number-range-max');
+            expect(validate(-7)).toThrowError('error.field-invalid-number-range-min');
+        });
+    });
+
+    describe('datetime', () => {
+
+        const validate = (value: string) => () => validateCustomFieldValue({
+            name: 'test',
+            type: 'datetime',
+            min: '2019-01-01T08:30',
+            max: '2019-06-01T08:30',
+        }, value);
+
+        it('passes valid range', () => {
+            expect(validate('2019-01-01T08:30:00.000')).not.toThrow();
+            expect(validate('2019-06-01T08:30:00.000')).not.toThrow();
+            expect(validate('2019-04-12T14:15:51.200')).not.toThrow();
+        });
+
+        it('throws on invalid range', () => {
+            expect(validate('2019-01-01T08:29:00.000')).toThrowError('error.field-invalid-datetime-range-min');
+            expect(validate('2019-06-01T08:30:00.100')).toThrowError('error.field-invalid-datetime-range-max');
+        });
+    });
+});

+ 65 - 0
packages/core/src/api/common/validate-custom-field-value.ts

@@ -0,0 +1,65 @@
+import { assertNever } from '@vendure/common/lib/shared-utils';
+
+import { UserInputError } from '../../common/error/errors';
+import {
+    CustomFieldConfig,
+    DateTimeCustomFieldConfig,
+    FloatCustomFieldConfig,
+    IntCustomFieldConfig,
+    LocaleStringCustomFieldConfig,
+    StringCustomFieldConfig,
+} from '../../config/custom-field/custom-field-types';
+
+/**
+ * Validates the value of a custom field input against any configured constraints.
+ * If validation fails, an error is thrown.
+ */
+export function validateCustomFieldValue(config: CustomFieldConfig, value: any): void {
+    switch (config.type) {
+        case 'string':
+        case 'localeString':
+            return validateStringField(config, value);
+            break;
+        case 'int':
+        case 'float':
+            return validateNumberField(config, value);
+            break;
+        case 'datetime':
+            return validateDateTimeField(config, value);
+            break;
+        case 'boolean':
+            break;
+        default:
+            assertNever(config);
+    }
+}
+
+function validateStringField(config: StringCustomFieldConfig | LocaleStringCustomFieldConfig, value: string): void {
+    const { pattern } = config;
+    if (pattern) {
+        const re = new RegExp(pattern);
+        if (!re.test(value)) {
+            throw new UserInputError('error.field-invalid-string-pattern', { value, pattern });
+        }
+    }
+}
+
+function validateNumberField(config: IntCustomFieldConfig | FloatCustomFieldConfig, value: number): void {
+    const { min, max } = config;
+    if (min != null && value < min) {
+        throw new UserInputError('error.field-invalid-number-range-min', { value, min });
+    }
+    if (max != null && max < value) {
+        throw new UserInputError('error.field-invalid-number-range-max', { value, max });
+    }
+}
+function validateDateTimeField(config: DateTimeCustomFieldConfig, value: string): void {
+    const { min, max } = config;
+    const valueDate = new Date(value);
+    if (min != null && valueDate < new Date(min)) {
+        throw new UserInputError('error.field-invalid-datetime-range-min', { value: valueDate.toISOString(), min });
+    }
+    if (max != null && new Date(max) < valueDate) {
+        throw new UserInputError('error.field-invalid-datetime-range-max', { value: valueDate.toISOString(), max });
+    }
+}

+ 21 - 0
packages/core/src/api/config/configure-graphql-module.ts

@@ -83,6 +83,25 @@ async function createGraphQLOptions(
         },
     };
 
+    const customFieldsConfigResolveType = {
+        __resolveType(value: any) {
+            switch (value.type) {
+                case 'string':
+                    return 'StringCustomFieldConfig';
+                case 'localeString':
+                    return 'LocaleStringCustomFieldConfig';
+                case 'int':
+                    return 'IntCustomFieldConfig';
+                case 'float':
+                    return 'FloatCustomFieldConfig';
+                case 'boolean':
+                    return 'BooleanCustomFieldConfig';
+                case 'datetime':
+                    return 'DateTimeCustomFieldConfig';
+            }
+        },
+    };
+
     return {
         path: '/' + options.apiPath,
         typeDefs: await createTypeDefs(options.apiType),
@@ -100,6 +119,8 @@ async function createGraphQLOptions(
             },
             StockMovementItem: stockMovementResolveType,
             StockMovement: stockMovementResolveType,
+            CustomFieldConfig: customFieldsConfigResolveType,
+            CustomField: customFieldsConfigResolveType,
         },
         uploads: {
             maxFileSize: configService.assetOptions.uploadMaxFileSize,

+ 125 - 0
packages/core/src/api/middleware/validate-custom-fields-interceptor.ts

@@ -0,0 +1,125 @@
+import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
+import { GqlExecutionContext } from '@nestjs/graphql';
+import {
+    DefinitionNode,
+    GraphQLInputType,
+    GraphQLList,
+    GraphQLNonNull,
+    GraphQLResolveInfo,
+    GraphQLSchema,
+    OperationDefinitionNode,
+    TypeNode,
+} from 'graphql';
+
+import { assertNever } from '../../../../common/lib/shared-utils';
+import { UserInputError } from '../../common/error/errors';
+import { ConfigService } from '../../config/config.service';
+import {
+    CustomFieldConfig,
+    CustomFields,
+    LocaleStringCustomFieldConfig,
+    StringCustomFieldConfig,
+} from '../../config/custom-field/custom-field-types';
+import { validateCustomFieldValue } from '../common/validate-custom-field-value';
+
+/**
+ * This interceptor is responsible for enforcing the validation constraints defined for any CustomFields.
+ * For example, if a custom 'int' field has a "min" value of 0, and a mutation attempts to set its value
+ * to a negative integer, then that mutation will fail with an error.
+ */
+@Injectable()
+export class ValidateCustomFieldsInterceptor implements NestInterceptor {
+    private readonly inputsWithCustomFields: Set<string>;
+
+    constructor(private configService: ConfigService) {
+        this.inputsWithCustomFields = Object.keys(configService.customFields).reduce((inputs, entityName) => {
+            inputs.add(`Create${entityName}Input`);
+            inputs.add(`Update${entityName}Input`);
+            return inputs;
+        }, new Set<string>());
+    }
+
+    intercept(context: ExecutionContext, next: CallHandler<any>) {
+        const ctx = GqlExecutionContext.create(context);
+        const { operation, schema } = ctx.getInfo<GraphQLResolveInfo>();
+        const variables = ctx.getArgs();
+
+        if (operation.operation === 'mutation') {
+            const inputTypeNames = this.getArgumentMap(operation, schema);
+            Object.entries(inputTypeNames).forEach(([inputName, typeName]) => {
+                if (this.inputsWithCustomFields.has(typeName)) {
+                    if (variables[inputName]) {
+                        this.validateInput(typeName, variables[inputName]);
+                    }
+                }
+            });
+        }
+        return next.handle();
+    }
+
+    private validateInput(typeName: string, variableValues?: { [key: string]: any }) {
+        if (variableValues) {
+            const entityName = typeName.replace(/(Create|Update)(.+)Input/, '$2');
+            const customFieldConfig = this.configService.customFields[entityName as keyof CustomFields];
+            if (customFieldConfig) {
+                if (variableValues.customFields) {
+                    this.validateCustomFieldsObject(customFieldConfig, variableValues.customFields);
+                }
+                const translations = variableValues.translations;
+                if (Array.isArray(translations)) {
+                    for (const translation of translations) {
+                        if (translation.customFields) {
+                            this.validateCustomFieldsObject(customFieldConfig, translation.customFields);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    private validateCustomFieldsObject(customFieldConfig: CustomFieldConfig[], customFieldsObject: { [key: string]: any; }) {
+        for (const [key, value] of Object.entries(customFieldsObject)) {
+            const config = customFieldConfig.find(c => c.name === key);
+            if (config) {
+                validateCustomFieldValue(config, value);
+            }
+        }
+    }
+
+    private getArgumentMap(operation: OperationDefinitionNode, schema: GraphQLSchema): { [inputName: string]: string; } {
+        const mutationType = schema.getMutationType();
+        if (!mutationType) {
+            return {};
+        }
+        const map: { [inputName: string]: string; } = {};
+
+        for (const selection of operation.selectionSet.selections) {
+            if (selection.kind === 'Field') {
+                const name = selection.name.value;
+                const inputType = mutationType.getFields()[name];
+                for (const arg of inputType.args) {
+                    map[arg.name] = this.getInputTypeName(arg.type);
+                }
+            }
+        }
+        return map;
+    }
+
+    private getNamedTypeName(type: TypeNode): string {
+        if (type.kind === 'NonNullType' || type.kind === 'ListType') {
+            return this.getNamedTypeName(type.type);
+        } else {
+            return type.name.value;
+        }
+    }
+
+    private getInputTypeName(type: GraphQLInputType): string {
+        if (type instanceof GraphQLNonNull) {
+            return this.getInputTypeName(type.ofType);
+        }
+        if (type instanceof GraphQLList) {
+            return this.getInputTypeName(type.ofType);
+        }
+        return type.name;
+    }
+}

+ 0 - 12
packages/core/src/api/schema/common/common-types.graphql

@@ -20,18 +20,6 @@ type Adjustment {
     amount: Int!
 }
 
-type CustomFieldConfig {
-    name: String!
-    type: String!
-    label: [LocalizedString!]
-    description: [LocalizedString!]
-}
-
-type LocalizedString {
-    languageCode: LanguageCode!
-    value: String!
-}
-
 """
 Certain entities allow arbitrary configuration arguments to be specified which can then
 be set in the admin-ui and used in the business logic of the app. These are the valid

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

@@ -0,0 +1,65 @@
+interface CustomField {
+    name: String!
+    type: String!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+}
+
+type StringCustomFieldConfig implements CustomField {
+    name: String!
+    type: String!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    pattern: String
+}
+type LocaleStringCustomFieldConfig implements CustomField {
+    name: String!
+    type: String!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    pattern: String
+}
+type IntCustomFieldConfig implements CustomField {
+    name: String!
+    type: String!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    min: Int
+    max: Int
+    step: Int
+}
+type FloatCustomFieldConfig implements CustomField {
+    name: String!
+    type: String!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    min: Float
+    max: Float
+    step: Float
+}
+type BooleanCustomFieldConfig implements CustomField {
+    name: String!
+    type: String!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+}
+"""
+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 DateTimeCustomFieldConfig implements CustomField {
+    name: String!
+    type: String!
+    label: [LocalizedString!]
+    description: [LocalizedString!]
+    min: String
+    max: String
+    step: Int
+}
+
+type LocalizedString {
+    languageCode: LanguageCode!
+    value: String!
+}
+
+union CustomFieldConfig = StringCustomFieldConfig | LocaleStringCustomFieldConfig | IntCustomFieldConfig | FloatCustomFieldConfig | BooleanCustomFieldConfig | DateTimeCustomFieldConfig

+ 23 - 9
packages/core/src/config/custom-field/custom-field-types.ts

@@ -1,4 +1,12 @@
-import { CustomFieldConfig as GraphQLCustomFieldConfig } from '@vendure/common/lib/generated-types';
+import { BooleanCustomFieldConfig as GraphQLBooleanCustomFieldConfig,
+    CustomField,
+    CustomFieldConfig as GraphQLCustomFieldConfig,
+    DateTimeCustomFieldConfig as GraphQLDateTimeCustomFieldConfig,
+    FloatCustomFieldConfig as GraphQLFloatCustomFieldConfig,
+    IntCustomFieldConfig as GraphQLIntCustomFieldConfig,
+    LocaleStringCustomFieldConfig as GraphQLLocaleStringCustomFieldConfig,
+    StringCustomFieldConfig as GraphQLStringCustomFieldConfig,
+} from '@vendure/common/lib/generated-types';
 import { CustomFieldsObject, CustomFieldType } from '@vendure/common/src/shared-types';
 
 // prettier-ignore
@@ -14,22 +22,28 @@ export type DefaultValueType<T extends CustomFieldType> =
  *
  * @docsCategory custom-fields
  */
-export type TypedCustomFieldConfig<T extends CustomFieldType = CustomFieldType> = Omit<
-    GraphQLCustomFieldConfig,
+export type TypedCustomFieldConfig<T extends CustomFieldType, C extends CustomField> = Omit<
+    C,
     '__typename'
 > & {
     type: T;
     defaultValue?: DefaultValueType<T>;
     nullable?: boolean;
 };
+export type StringCustomFieldConfig = TypedCustomFieldConfig<'string', GraphQLStringCustomFieldConfig>;
+export type LocaleStringCustomFieldConfig = TypedCustomFieldConfig<'localeString', GraphQLLocaleStringCustomFieldConfig>;
+export type IntCustomFieldConfig = TypedCustomFieldConfig<'int', GraphQLIntCustomFieldConfig>;
+export type FloatCustomFieldConfig = TypedCustomFieldConfig<'float', GraphQLFloatCustomFieldConfig>;
+export type BooleanCustomFieldConfig = TypedCustomFieldConfig<'boolean', GraphQLBooleanCustomFieldConfig>;
+export type DateTimeCustomFieldConfig = TypedCustomFieldConfig<'datetime', GraphQLDateTimeCustomFieldConfig>;
 
 export type CustomFieldConfig =
-    | TypedCustomFieldConfig<'string'>
-    | TypedCustomFieldConfig<'localeString'>
-    | TypedCustomFieldConfig<'int'>
-    | TypedCustomFieldConfig<'float'>
-    | TypedCustomFieldConfig<'boolean'>
-    | TypedCustomFieldConfig<'datetime'>;
+    | StringCustomFieldConfig
+    | LocaleStringCustomFieldConfig
+    | IntCustomFieldConfig
+    | FloatCustomFieldConfig
+    | BooleanCustomFieldConfig
+    | DateTimeCustomFieldConfig;
 
 /**
  * @description

+ 5 - 2
packages/core/src/entity/register-custom-entity-fields.ts

@@ -65,14 +65,17 @@ function registerCustomFieldsForEntity(
 }
 
 function formatDefaultDatetime(dbEngine: ConnectionOptions['type'], datetime: any): Date | string {
+    if (!datetime) {
+        return datetime;
+    }
     switch (dbEngine) {
         case 'sqlite':
         case 'sqljs':
-            return DateUtils.mixedDateToDatetimeString(datetime);
+            return DateUtils.mixedDateToUtcDatetimeString(datetime);
         case 'mysql':
         case 'postgres':
         default:
-            return DateUtils.mixedDateToDate(datetime);
+            return DateUtils.mixedDateToDate(datetime, true, true);
     }
 }
 

+ 5 - 0
packages/core/src/i18n/messages/en.json

@@ -21,6 +21,11 @@
     "email-address-not-verified": "Please verify this email address before logging in",
     "entity-has-no-translation-in-language": "Translatable entity '{ entityName }' has not been translated into the requested language ({ languageCode })",
     "entity-with-id-not-found": "No { entityName } with the id '{ id }' could be found",
+    "field-invalid-datetime-range-max": "The custom field value [{ value }] is greater than the maximum [{ max }]",
+    "field-invalid-datetime-range-min": "The custom field value [{ value }] is less than the minimum [{ min }]",
+    "field-invalid-number-range-max": "The custom field value [{ value }] is greater than the maximum [{ max }]",
+    "field-invalid-number-range-min": "The custom field value [{ value }] is less than the minimum [{ min }]",
+    "field-invalid-string-pattern": "The custom field value ['{ value }'] does not match the pattern [{ pattern }]",
     "forbidden": "You are not currently authorized to perform this action",
     "identifier-change-token-not-recognized": "Identifier change token not recognized",
     "identifier-change-token-has-expired": "Identifier change token has expired",

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
schema-admin.json


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
schema-shop.json


Неке датотеке нису приказане због велике количине промена