Browse Source

feat(server): Extend inputs & translations with customFields

Michael Bromley 7 years ago
parent
commit
834481314c

+ 1 - 1
server/package.json

@@ -29,7 +29,7 @@
     "apollo-server-express": "^1.3.6",
     "bcrypt": "^2.0.1",
     "body-parser": "^1.18.3",
-    "graphql": "^0.13.2",
+    "graphql": "^14.0.0-rc.2",
     "graphql-tools": "^3.0.2",
     "graphql-type-json": "^0.2.1",
     "i18next": "^11.3.3",

+ 3 - 3
server/src/app.module.ts

@@ -15,7 +15,7 @@ import { AuthService } from './auth/auth.service';
 import { JwtStrategy } from './auth/jwt.strategy';
 import { PasswordService } from './auth/password.service';
 import { getConfig } from './config/vendure-config';
-import { generateGraphQlCustomFieldsTypes } from './entity/graphql-custom-fields';
+import { addGraphQLCustomFields } from './entity/graphql-custom-fields';
 import { I18nService } from './i18n/i18n.service';
 import { TranslationUpdaterService } from './locale/translation-updater.service';
 import { AdministratorService } from './service/administrator.service';
@@ -83,9 +83,9 @@ export class AppModule implements NestModule {
 
     createSchema() {
         const typeDefs = this.graphQLFactory.mergeTypesByPaths(__dirname + '/**/*.graphql');
-        const customFieldTypeDefs = generateGraphQlCustomFieldsTypes(config.customFields);
+        const extendedTypeDefs = addGraphQLCustomFields(typeDefs, config.customFields);
         return this.graphQLFactory.createSchema({
-            typeDefs: typeDefs + customFieldTypeDefs,
+            typeDefs: extendedTypeDefs,
             resolverValidationOptions: {
                 requireResolversForResolveType: false,
             },

+ 1 - 18
server/src/config/vendure-config.ts

@@ -1,7 +1,7 @@
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { ConnectionOptions } from 'typeorm';
 
-import { DeepPartial } from '../../../shared/shared-types';
+import { CustomFields, DeepPartial } from '../../../shared/shared-types';
 import { ReadOnlyRequired } from '../common/common-types';
 import { LanguageCode } from '../locale/language-code';
 
@@ -9,23 +9,6 @@ import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { mergeConfig } from './merge-config';
 
-export type CustomFieldType = 'string' | 'localeString' | 'int' | 'float' | 'boolean' | 'datetime';
-
-export interface CustomFieldConfig {
-    name: string;
-    type: CustomFieldType;
-}
-
-export interface CustomFields {
-    Address?: CustomFieldConfig[];
-    Customer?: CustomFieldConfig[];
-    Product?: CustomFieldConfig[];
-    ProductOption?: CustomFieldConfig[];
-    ProductOptionGroup?: CustomFieldConfig[];
-    ProductVariant?: CustomFieldConfig[];
-    User?: CustomFieldConfig[];
-}
-
 export interface VendureConfig {
     /**
      * The default languageCode of the app.

+ 120 - 0
server/src/entity/__snapshots__/graphql-custom-fields.spec.ts.snap

@@ -0,0 +1,120 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`addGraphQLCustomFields() extends a type 1`] = `
+"type Product {
+  id: ID
+  customFields: ProductCustomFields
+}
+
+type ProductCustomFields {
+  available: Boolean
+}
+"
+`;
+
+exports[`addGraphQLCustomFields() extends a type with a Create input 1`] = `
+"input CreateProductCustomFieldsInput {
+  available: Boolean
+}
+
+input CreateProductInput {
+  image: String
+  customFields: CreateProductCustomFieldsInput
+}
+
+type Product {
+  id: ID
+  customFields: ProductCustomFields
+}
+
+type ProductCustomFields {
+  available: Boolean
+  shortName: String
+}
+"
+`;
+
+exports[`addGraphQLCustomFields() extends a type with a Create input and a translation 1`] = `
+"input CreateProductCustomFieldsInput {
+  available: Boolean
+}
+
+input CreateProductInput {
+  image: String
+  customFields: CreateProductCustomFieldsInput
+}
+
+type Product {
+  id: ID
+  customFields: ProductCustomFields
+}
+
+type ProductCustomFields {
+  available: Boolean
+  shortName: String
+}
+
+type ProductTranslation {
+  id: ID
+  customFields: ProductTranslationCustomFields
+}
+
+type ProductTranslationCustomFields {
+  shortName: String
+}
+
+input ProductTranslationCustomFieldsInput {
+  shortName: String
+}
+
+input ProductTranslationInput {
+  id: ID
+  customFields: ProductTranslationCustomFieldsInput
+}
+"
+`;
+
+exports[`addGraphQLCustomFields() extends a type with a translation 1`] = `
+"type Product {
+  id: ID
+  translations: [ProductTranslation!]!
+  customFields: ProductCustomFields
+}
+
+type ProductCustomFields {
+  available: Boolean
+  shortName: String
+}
+
+type ProductTranslation {
+  id: ID
+  customFields: ProductTranslationCustomFields
+}
+
+type ProductTranslationCustomFields {
+  shortName: String
+}
+"
+`;
+
+exports[`addGraphQLCustomFields() extends a type with an Update input 1`] = `
+"type Product {
+  id: ID
+  customFields: ProductCustomFields
+}
+
+type ProductCustomFields {
+  available: Boolean
+  shortName: String
+}
+
+input UpdateProductCustomFieldsInput {
+  available: Boolean
+}
+
+input UpdateProductInput {
+  image: String
+  customFields: UpdateProductCustomFieldsInput
+}
+"
+`;

+ 1 - 1
server/src/entity/address/address.entity.ts

@@ -1,8 +1,8 @@
 import { Column, Entity, ManyToOne } from 'typeorm';
 
 import { DeepPartial } from '../../../../shared/shared-types';
+import { HasCustomFields } from '../../../../shared/shared-types';
 import { VendureEntity } from '../base/base.entity';
-import { HasCustomFields } from '../base/has-custom-fields';
 import { CustomAddressFields } from '../custom-entity-fields';
 import { Customer } from '../customer/customer.entity';
 

+ 0 - 9
server/src/entity/base/has-custom-fields.ts

@@ -1,9 +0,0 @@
-/**
- * This interface should be implemented by any entity which can be extended
- * with custom fields.
- */
-export interface HasCustomFields {
-    customFields: CustomFieldsObject;
-}
-
-export type CustomFieldsObject = object;

+ 2 - 1
server/src/entity/custom-entity-fields.ts

@@ -1,7 +1,8 @@
 import { Column, ColumnType, Entity } from 'typeorm';
 
+import { CustomFields, CustomFieldType } from '../../../shared/shared-types';
 import { assertNever } from '../../../shared/shared-utils';
-import { CustomFields, CustomFieldType, getConfig } from '../config/vendure-config';
+import { getConfig } from '../config/vendure-config';
 
 const config = getConfig();
 

+ 1 - 1
server/src/entity/customer/customer.entity.ts

@@ -1,9 +1,9 @@
 import { Column, Entity, JoinColumn, OneToMany, OneToOne } from 'typeorm';
 
 import { DeepPartial } from '../../../../shared/shared-types';
+import { HasCustomFields } from '../../../../shared/shared-types';
 import { Address } from '../address/address.entity';
 import { VendureEntity } from '../base/base.entity';
-import { HasCustomFields } from '../base/has-custom-fields';
 import { CustomCustomerFields } from '../custom-entity-fields';
 import { User } from '../user/user.entity';
 

+ 95 - 0
server/src/entity/graphql-custom-fields.spec.ts

@@ -0,0 +1,95 @@
+import { CustomFields } from '../../../shared/shared-types';
+
+import { addGraphQLCustomFields } from './graphql-custom-fields';
+
+describe('addGraphQLCustomFields()', () => {
+    it('extends a type', () => {
+        const input = `
+            type Product {
+                id: ID
+            }
+        `;
+        const customFieldConfig: CustomFields = {
+            Product: [{ name: 'available', type: 'boolean' }],
+        };
+        const result = addGraphQLCustomFields(input, customFieldConfig);
+        expect(result).toMatchSnapshot();
+    });
+
+    it('extends a type with a translation', () => {
+        const input = `
+                    type Product {
+                        id: ID
+                        translations: [ProductTranslation!]!
+                    }
+
+                    type ProductTranslation {
+                        id: ID
+                    }
+                `;
+        const customFieldConfig: CustomFields = {
+            Product: [{ name: 'available', type: 'boolean' }, { name: 'shortName', type: 'localeString' }],
+        };
+        const result = addGraphQLCustomFields(input, customFieldConfig);
+        expect(result).toMatchSnapshot();
+    });
+
+    it('extends a type with a Create input', () => {
+        const input = `
+                    type Product {
+                        id: ID
+                    }
+
+                    input CreateProductInput {
+                        image: String
+                    }
+                `;
+        const customFieldConfig: CustomFields = {
+            Product: [{ name: 'available', type: 'boolean' }, { name: 'shortName', type: 'localeString' }],
+        };
+        const result = addGraphQLCustomFields(input, customFieldConfig);
+        expect(result).toMatchSnapshot();
+    });
+
+    it('extends a type with an Update input', () => {
+        const input = `
+                    type Product {
+                        id: ID
+                    }
+
+                    input UpdateProductInput {
+                        image: String
+                    }
+                `;
+        const customFieldConfig: CustomFields = {
+            Product: [{ name: 'available', type: 'boolean' }, { name: 'shortName', type: 'localeString' }],
+        };
+        const result = addGraphQLCustomFields(input, customFieldConfig);
+        expect(result).toMatchSnapshot();
+    });
+
+    it('extends a type with a Create input and a translation', () => {
+        const input = `
+                    type Product {
+                        id: ID
+                    }
+
+                    type ProductTranslation {
+                        id: ID
+                    }
+
+                    input ProductTranslationInput {
+                        id: ID
+                    }
+
+                    input CreateProductInput {
+                        image: String
+                    }
+                `;
+        const customFieldConfig: CustomFields = {
+            Product: [{ name: 'available', type: 'boolean' }, { name: 'shortName', type: 'localeString' }],
+        };
+        const result = addGraphQLCustomFields(input, customFieldConfig);
+        expect(result).toMatchSnapshot();
+    });
+});

+ 79 - 13
server/src/entity/graphql-custom-fields.ts

@@ -1,38 +1,104 @@
+import { buildSchema, extendSchema, parse, printSchema } from 'graphql';
+
+import { CustomFieldConfig, CustomFields, CustomFieldType } from '../../../shared/shared-types';
 import { assertNever } from '../../../shared/shared-utils';
-import { CustomFields, CustomFieldType } from '../config/vendure-config';
 
 /**
  * Given a CustomFields config object, generates an SDL string extending the built-in
- * types with a customFields property.
+ * types with a customFields property for all entities, translations and inputs for which
+ * custom fields are defined.
  */
-export function generateGraphQlCustomFieldsTypes(customFieldConfig?: CustomFields): string {
+export function addGraphQLCustomFields(typeDefs: string, customFieldConfig?: CustomFields): string {
+    const schema = buildSchema(typeDefs);
+
     if (!customFieldConfig) {
-        return '';
+        return typeDefs;
     }
 
     let customFieldTypeDefs = '';
+
     for (const entityName of Object.keys(customFieldConfig)) {
         const customEntityFields = customFieldConfig[entityName as keyof CustomFields];
 
         if (customEntityFields) {
-            customFieldTypeDefs += `
-            type ${entityName}CustomFields {
-                ${customEntityFields.map(field => `${field.name}: ${getGraphQlType(field.type)}`).join('\n')}
+            const localeStringFields = customEntityFields.filter(field => field.type === 'localeString');
+            const nonLocaleStringFields = customEntityFields.filter(field => field.type !== 'localeString');
+
+            if (schema.getType(entityName)) {
+                customFieldTypeDefs += `
+                    type ${entityName}CustomFields {
+                        ${mapToFields(customEntityFields)}
+                    }
+
+                    extend type ${entityName} {
+                        customFields: ${entityName}CustomFields
+                    }
+                `;
             }
 
-            extend type ${entityName} {
-                customFields: ${entityName}CustomFields
+            if (localeStringFields.length && schema.getType(`${entityName}Translation`)) {
+                customFieldTypeDefs += `
+                    type ${entityName}TranslationCustomFields {
+                         ${mapToFields(localeStringFields)}
+                    }
+
+                    extend type ${entityName}Translation {
+                        customFields: ${entityName}TranslationCustomFields
+                    }
+                `;
+            }
+
+            if (schema.getType(`Create${entityName}Input`)) {
+                customFieldTypeDefs += `
+                    input Create${entityName}CustomFieldsInput {
+                       ${mapToFields(nonLocaleStringFields)}
+                    }
+
+                    extend input Create${entityName}Input {
+                        customFields: Create${entityName}CustomFieldsInput
+                    }
+                `;
+            }
+
+            if (schema.getType(`Update${entityName}Input`)) {
+                customFieldTypeDefs += `
+                    input Update${entityName}CustomFieldsInput {
+                       ${mapToFields(nonLocaleStringFields)}
+                    }
+
+                    extend input Update${entityName}Input {
+                        customFields: Update${entityName}CustomFieldsInput
+                    }
+                `;
+            }
+
+            if (localeStringFields && schema.getType(`${entityName}TranslationInput`)) {
+                customFieldTypeDefs += `
+                    input ${entityName}TranslationCustomFieldsInput {
+                        ${mapToFields(localeStringFields)}
+                    }
+
+                    extend input ${entityName}TranslationInput {
+                        customFields: ${entityName}TranslationCustomFieldsInput
+                    }
+                `;
             }
-        `;
         }
     }
 
-    return customFieldTypeDefs;
+    return printSchema(extendSchema(schema, parse(customFieldTypeDefs)));
 }
 
-type GraphQLSDLType = 'String' | 'Int' | 'Float' | 'Boolean' | 'ID';
+type GraphQLFieldType = 'String' | 'Int' | 'Float' | 'Boolean' | 'ID';
+
+/**
+ * Maps an array of CustomFieldConfig objects into a string of SDL fields.
+ */
+function mapToFields(fieldDefs: CustomFieldConfig[]): string {
+    return fieldDefs.map(field => `${field.name}: ${getGraphQlType(field.type)}`).join('\n');
+}
 
-function getGraphQlType(type: CustomFieldType): GraphQLSDLType {
+function getGraphQlType(type: CustomFieldType): GraphQLFieldType {
     switch (type) {
         case 'string':
         case 'datetime':

+ 1 - 1
server/src/entity/product-option-group/product-option-group-translation.entity.ts

@@ -1,10 +1,10 @@
 import { Column, Entity, ManyToOne } from 'typeorm';
 
 import { DeepPartial } from '../../../../shared/shared-types';
+import { HasCustomFields } from '../../../../shared/shared-types';
 import { LanguageCode } from '../../locale/language-code';
 import { Translation } from '../../locale/locale-types';
 import { VendureEntity } from '../base/base.entity';
-import { HasCustomFields } from '../base/has-custom-fields';
 import { CustomProductOptionGroupFieldsTranslation } from '../custom-entity-fields';
 
 import { ProductOptionGroup } from './product-option-group.entity';

+ 1 - 1
server/src/entity/product-option-group/product-option-group.entity.ts

@@ -1,9 +1,9 @@
 import { Column, Entity, OneToMany } from 'typeorm';
 
 import { DeepPartial } from '../../../../shared/shared-types';
+import { HasCustomFields } from '../../../../shared/shared-types';
 import { LocaleString, Translatable, Translation } from '../../locale/locale-types';
 import { VendureEntity } from '../base/base.entity';
-import { HasCustomFields } from '../base/has-custom-fields';
 import { CustomProductOptionGroupFields } from '../custom-entity-fields';
 import { ProductOption } from '../product-option/product-option.entity';
 

+ 1 - 1
server/src/entity/product-option/product-option-translation.entity.ts

@@ -1,10 +1,10 @@
 import { Column, Entity, ManyToOne } from 'typeorm';
 
 import { DeepPartial } from '../../../../shared/shared-types';
+import { HasCustomFields } from '../../../../shared/shared-types';
 import { LanguageCode } from '../../locale/language-code';
 import { Translation } from '../../locale/locale-types';
 import { VendureEntity } from '../base/base.entity';
-import { HasCustomFields } from '../base/has-custom-fields';
 import { CustomProductOptionFieldsTranslation } from '../custom-entity-fields';
 
 import { ProductOption } from './product-option.entity';

+ 1 - 1
server/src/entity/product-option/product-option.entity.ts

@@ -1,9 +1,9 @@
 import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 
 import { DeepPartial } from '../../../../shared/shared-types';
+import { HasCustomFields } from '../../../../shared/shared-types';
 import { LocaleString, Translatable, Translation } from '../../locale/locale-types';
 import { VendureEntity } from '../base/base.entity';
-import { HasCustomFields } from '../base/has-custom-fields';
 import { CustomProductOptionFields } from '../custom-entity-fields';
 import { ProductOptionGroup } from '../product-option-group/product-option-group.entity';
 

+ 1 - 1
server/src/entity/product-variant/product-variant-translation.entity.ts

@@ -1,10 +1,10 @@
 import { Column, Entity, ManyToOne } from 'typeorm';
 
 import { DeepPartial } from '../../../../shared/shared-types';
+import { HasCustomFields } from '../../../../shared/shared-types';
 import { LanguageCode } from '../../locale/language-code';
 import { Translation } from '../../locale/locale-types';
 import { VendureEntity } from '../base/base.entity';
-import { HasCustomFields } from '../base/has-custom-fields';
 import { CustomProductVariantFieldsTranslation } from '../custom-entity-fields';
 
 import { ProductVariant } from './product-variant.entity';

+ 1 - 1
server/src/entity/product-variant/product-variant.entity.ts

@@ -1,9 +1,9 @@
 import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
 import { DeepPartial } from '../../../../shared/shared-types';
+import { HasCustomFields } from '../../../../shared/shared-types';
 import { LocaleString, Translatable, Translation } from '../../locale/locale-types';
 import { VendureEntity } from '../base/base.entity';
-import { HasCustomFields } from '../base/has-custom-fields';
 import { CustomProductVariantFields } from '../custom-entity-fields';
 import { ProductOption } from '../product-option/product-option.entity';
 import { Product } from '../product/product.entity';

+ 1 - 1
server/src/entity/product/product-translation.entity.ts

@@ -1,10 +1,10 @@
 import { Column, Entity, ManyToOne } from 'typeorm';
 
 import { DeepPartial } from '../../../../shared/shared-types';
+import { HasCustomFields } from '../../../../shared/shared-types';
 import { LanguageCode } from '../../locale/language-code';
 import { Translation, TranslationInput } from '../../locale/locale-types';
 import { VendureEntity } from '../base/base.entity';
-import { HasCustomFields } from '../base/has-custom-fields';
 import { CustomProductFieldsTranslation } from '../custom-entity-fields';
 
 import { Product } from './product.entity';

+ 1 - 1
server/src/entity/product/product.entity.ts

@@ -1,9 +1,9 @@
 import { Column, Entity, JoinTable, ManyToMany, OneToMany } from 'typeorm';
 
 import { DeepPartial } from '../../../../shared/shared-types';
+import { HasCustomFields } from '../../../../shared/shared-types';
 import { LocaleString, Translatable, Translation } from '../../locale/locale-types';
 import { VendureEntity } from '../base/base.entity';
-import { HasCustomFields } from '../base/has-custom-fields';
 import { CustomProductFields } from '../custom-entity-fields';
 import { ProductOptionGroup } from '../product-option-group/product-option-group.entity';
 import { ProductVariant } from '../product-variant/product-variant.entity';

+ 1 - 1
server/src/entity/user/user.entity.ts

@@ -1,9 +1,9 @@
 import { Column, Entity } from 'typeorm';
 
 import { DeepPartial } from '../../../../shared/shared-types';
+import { HasCustomFields } from '../../../../shared/shared-types';
 import { Role } from '../../auth/role';
 import { VendureEntity } from '../base/base.entity';
-import { HasCustomFields } from '../base/has-custom-fields';
 import { CustomUserFields } from '../custom-entity-fields';
 
 @Entity()

+ 1 - 1
server/src/locale/locale-types.ts

@@ -1,7 +1,7 @@
 import { ID } from '../../../shared/shared-types';
+import { CustomFieldsObject } from '../../../shared/shared-types';
 import { UnwrappedArray } from '../common/common-types';
 import { VendureEntity } from '../entity/base/base.entity';
-import { CustomFieldsObject } from '../entity/base/has-custom-fields';
 
 import { LanguageCode } from './language-code';
 import { TranslatableRelationsKeys } from './translate-entity';

+ 2 - 1
server/src/service/config.service.ts

@@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common';
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { ConnectionOptions } from 'typeorm';
 
+import { CustomFields } from '../../../shared/shared-types';
 import { ReadOnlyRequired } from '../common/common-types';
 import { EntityIdStrategy } from '../config/entity-id-strategy/entity-id-strategy';
-import { CustomFields, getConfig, VendureConfig } from '../config/vendure-config';
+import { getConfig, VendureConfig } from '../config/vendure-config';
 import { LanguageCode } from '../locale/language-code';
 
 @Injectable()

+ 5 - 5
server/yarn.lock

@@ -2117,11 +2117,11 @@ graphql-type-json@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/graphql-type-json/-/graphql-type-json-0.2.1.tgz#d2c177e2f1b17d87f81072cd05311c0754baa420"
 
-graphql@^0.13.2:
-  version "0.13.2"
-  resolved "https://registry.yarnpkg.com/graphql/-/graphql-0.13.2.tgz#4c740ae3c222823e7004096f832e7b93b2108270"
+graphql@^14.0.0-rc.2:
+  version "14.0.0-rc.2"
+  resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.0.0-rc.2.tgz#a5d6795e6c08b6f04abf471d7c0565b7fa79ea10"
   dependencies:
-    iterall "^1.2.1"
+    iterall "^1.2.2"
 
 growly@^1.3.0:
   version "1.3.0"
@@ -2745,7 +2745,7 @@ istanbul-reports@^1.3.0:
   dependencies:
     handlebars "^4.0.3"
 
-iterall@^1.1.3, iterall@^1.2.1:
+iterall@^1.1.3, iterall@^1.2.2:
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7"
 

+ 27 - 0
shared/shared-types.ts

@@ -23,3 +23,30 @@ export type PaginatedList<T> = {
  * An entity ID
  */
 export type ID = string | number;
+
+export type CustomFieldType = 'string' | 'localeString' | 'int' | 'float' | 'boolean' | 'datetime';
+
+export interface CustomFieldConfig {
+    name: string;
+    type: CustomFieldType;
+}
+
+export interface CustomFields {
+    Address?: CustomFieldConfig[];
+    Customer?: CustomFieldConfig[];
+    Product?: CustomFieldConfig[];
+    ProductOption?: CustomFieldConfig[];
+    ProductOptionGroup?: CustomFieldConfig[];
+    ProductVariant?: CustomFieldConfig[];
+    User?: CustomFieldConfig[];
+}
+
+/**
+ * This interface should be implemented by any entity which can be extended
+ * with custom fields.
+ */
+export interface HasCustomFields {
+    customFields: CustomFieldsObject;
+}
+
+export type CustomFieldsObject = object;