Browse Source

feat(admin-ui): Make readonly custom fields readonly in the UI

Also intercepts mutations to remove any readonly custom fields from the input object, which would otherwise cause throw a server error. Relates to #216
Michael Bromley 6 years ago
parent
commit
cf1d7f1844

+ 1 - 1
packages/admin-ui/src/app/common/base-detail.component.ts

@@ -68,7 +68,7 @@ export abstract class BaseDetailComponent<Entity extends { id: string; updatedAt
     protected abstract setFormValues(entity: Entity, languageCode: LanguageCode): void;
 
     protected getCustomFieldConfig(key: Exclude<keyof CustomFields, '__typename'>): CustomFieldConfig[] {
-        return this.serverConfigService.serverConfig.customFieldConfig[key] || [];
+        return this.serverConfigService.getCustomFieldsFor(key);
     }
 
     protected setQueryParam(key: string, value: any) {

+ 4 - 19
packages/admin-ui/src/app/common/generated-types.ts

@@ -487,16 +487,12 @@ export type CreateGroupOptionInput = {
   translations: Array<ProductOptionGroupTranslationInput>,
 };
 
-export type CreateProductCustomFieldsInput = {
-  rating?: Maybe<Scalars['Float']>,
-};
-
 export type CreateProductInput = {
   featuredAssetId?: Maybe<Scalars['ID']>,
   assetIds?: Maybe<Array<Scalars['ID']>>,
   facetValueIds?: Maybe<Array<Scalars['ID']>>,
   translations: Array<ProductTranslationInput>,
-  customFields?: Maybe<CreateProductCustomFieldsInput>,
+  customFields?: Maybe<Scalars['JSON']>,
 };
 
 export type CreateProductOptionGroupInput = {
@@ -2508,12 +2504,7 @@ export type Product = Node & {
   facetValues: Array<FacetValue>,
   translations: Array<ProductTranslation>,
   collections: Array<Collection>,
-  customFields?: Maybe<ProductCustomFields>,
-};
-
-export type ProductCustomFields = {
-  __typename?: 'ProductCustomFields',
-  rating?: Maybe<Scalars['Float']>,
+  customFields?: Maybe<Scalars['JSON']>,
 };
 
 export type ProductFilterParameter = {
@@ -2524,7 +2515,6 @@ export type ProductFilterParameter = {
   name?: Maybe<StringOperators>,
   slug?: Maybe<StringOperators>,
   description?: Maybe<StringOperators>,
-  rating?: Maybe<NumberOperators>,
 };
 
 export type ProductList = PaginatedList & {
@@ -2605,7 +2595,6 @@ export type ProductSortParameter = {
   name?: Maybe<SortOrder>,
   slug?: Maybe<SortOrder>,
   description?: Maybe<SortOrder>,
-  rating?: Maybe<SortOrder>,
 };
 
 export type ProductTranslation = {
@@ -3473,10 +3462,6 @@ export type UpdatePaymentMethodInput = {
   configArgs?: Maybe<Array<ConfigArgInput>>,
 };
 
-export type UpdateProductCustomFieldsInput = {
-  rating?: Maybe<Scalars['Float']>,
-};
-
 export type UpdateProductInput = {
   id: Scalars['ID'],
   enabled?: Maybe<Scalars['Boolean']>,
@@ -3484,7 +3469,7 @@ export type UpdateProductInput = {
   assetIds?: Maybe<Array<Scalars['ID']>>,
   facetValueIds?: Maybe<Array<Scalars['ID']>>,
   translations?: Maybe<Array<ProductTranslationInput>>,
-  customFields?: Maybe<UpdateProductCustomFieldsInput>,
+  customFields?: Maybe<Scalars['JSON']>,
 };
 
 export type UpdateProductOptionGroupInput = {
@@ -4422,7 +4407,7 @@ export type UpdateGlobalSettingsMutationVariables = {
 
 export type UpdateGlobalSettingsMutation = ({ __typename?: 'Mutation' } & { updateGlobalSettings: ({ __typename?: 'GlobalSettings' } & GlobalSettingsFragment) });
 
-export type CustomFieldConfigFragment = ({ __typename?: 'StringCustomFieldConfig' | 'LocaleStringCustomFieldConfig' | 'IntCustomFieldConfig' | 'FloatCustomFieldConfig' | 'BooleanCustomFieldConfig' | 'DateTimeCustomFieldConfig' } & Pick<CustomField, 'name' | 'type'> & { description: Maybe<Array<({ __typename?: 'LocalizedString' } & Pick<LocalizedString, 'languageCode' | 'value'>)>>, label: Maybe<Array<({ __typename?: 'LocalizedString' } & Pick<LocalizedString, 'languageCode' | 'value'>)>> });
+export type CustomFieldConfigFragment = ({ __typename?: 'StringCustomFieldConfig' | 'LocaleStringCustomFieldConfig' | 'IntCustomFieldConfig' | 'FloatCustomFieldConfig' | 'BooleanCustomFieldConfig' | 'DateTimeCustomFieldConfig' } & Pick<CustomField, 'name' | 'type' | 'readonly'> & { description: Maybe<Array<({ __typename?: 'LocalizedString' } & Pick<LocalizedString, 'languageCode' | 'value'>)>>, label: Maybe<Array<({ __typename?: 'LocalizedString' } & Pick<LocalizedString, 'languageCode' | 'value'>)>> });
 
 export type StringCustomFieldFragment = ({ __typename?: 'StringCustomFieldConfig' } & Pick<StringCustomFieldConfig, 'pattern'> & { options: Maybe<Array<({ __typename?: 'StringFieldOption' } & Pick<StringFieldOption, 'value'> & { label: Maybe<Array<({ __typename?: 'LocalizedString' } & Pick<LocalizedString, 'languageCode' | 'value'>)>> })>> } & CustomFieldConfigFragment);
 

+ 0 - 1
packages/admin-ui/src/app/common/utilities/create-updated-translatable.ts

@@ -28,7 +28,6 @@ export function createUpdatedTranslatable<T extends { translations: any[] } & Ma
         translatable.translations.find(t => t.languageCode === languageCode) || defaultTranslation;
     const index = translatable.translations.indexOf(currentTranslation);
     const newTranslation = patchObject(currentTranslation, updatedFields);
-    const customFields = translatable.customFields;
     const newCustomFields: CustomFieldsObject = {};
     const newTranslatedCustomFields: CustomFieldsObject = {};
     if (customFieldConfig && updatedFields.hasOwnProperty('customFields')) {

+ 1 - 0
packages/admin-ui/src/app/data/definitions/settings-definitions.ts

@@ -413,6 +413,7 @@ export const CUSTOM_FIELD_CONFIG_FRAGMENT = gql`
             languageCode
             value
         }
+        readonly
     }
 `;
 

+ 22 - 6
packages/admin-ui/src/app/data/providers/base-data.service.ts

@@ -1,19 +1,22 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
+import {
+    isEntityCreateOrUpdateMutation,
+    removeReadonlyCustomFields,
+} from '@vendure/admin-ui/src/app/data/utils/remove-readonly-custom-fields';
 import { Apollo } from 'apollo-angular';
 import { DataProxy } from 'apollo-cache';
 import { WatchQueryFetchPolicy } from 'apollo-client';
 import { ExecutionResult } from 'apollo-link';
 import { DocumentNode } from 'graphql/language/ast';
-import { merge, Observable } from 'rxjs';
-import { delay, distinctUntilChanged, filter, map, skip, takeUntil } from 'rxjs/operators';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
 
-import { CustomFields, GetUserStatus } from '../../common/generated-types';
+import { CustomFields } from '../../common/generated-types';
 import { LocalStorageService } from '../../core/providers/local-storage/local-storage.service';
-import { addCustomFields } from '../add-custom-fields';
-import { GET_USER_STATUS } from '../definitions/client-definitions';
 import { QueryResult } from '../query-result';
 import { ServerConfigService } from '../server-config';
+import { addCustomFields } from '../utils/add-custom-fields';
 
 /**
  * Make the MutationUpdaterFn type-safe until this issue is resolved: https://github.com/apollographql/apollo-link/issues/616
@@ -64,12 +67,25 @@ export class BaseDataService {
         update?: TypedMutationUpdateFn<T>,
     ): Observable<T> {
         const withCustomFields = addCustomFields(mutation, this.customFields);
+        const withoutReadonlyFields = this.removeReadonlyCustomFieldsFromVariables(mutation, variables);
+
         return this.apollo
             .mutate<T, V>({
                 mutation: withCustomFields,
-                variables,
+                variables: withoutReadonlyFields,
                 update: update as any,
             })
             .pipe(map(result => result.data as T));
     }
+
+    private removeReadonlyCustomFieldsFromVariables<V>(mutation: DocumentNode, variables: V): V {
+        const entity = isEntityCreateOrUpdateMutation(mutation);
+        if (entity) {
+            const customFieldConfig = this.customFields[entity];
+            if (variables && customFieldConfig) {
+                return removeReadonlyCustomFields(variables, customFieldConfig);
+            }
+        }
+        return variables;
+    }
 }

+ 14 - 1
packages/admin-ui/src/app/data/server-config.ts

@@ -1,7 +1,13 @@
 import { Injectable, Injector } from '@angular/core';
 import gql from 'graphql-tag';
 
-import { GetGlobalSettings, GetServerConfig, ServerConfig } from '../common/generated-types';
+import {
+    CustomFieldConfig,
+    CustomFields,
+    GetGlobalSettings,
+    GetServerConfig,
+    ServerConfig,
+} from '../common/generated-types';
 
 import { GET_GLOBAL_SETTINGS, GET_SERVER_CONFIG } from './definitions/settings-definitions';
 import { BaseDataService } from './providers/base-data.service';
@@ -63,6 +69,13 @@ export class ServerConfigService {
             .single$;
     }
 
+    /**
+     * Retrieves the custom field configs for the given entity type.
+     */
+    getCustomFieldsFor(type: Exclude<keyof CustomFields, '__typename'>): CustomFieldConfig[] {
+        return this.serverConfig.customFieldConfig[type] || [];
+    }
+
     get serverConfig(): ServerConfig {
         return this._serverConfig;
     }

+ 1 - 1
packages/admin-ui/src/app/data/add-custom-fields.spec.ts → packages/admin-ui/src/app/data/utils/add-custom-fields.spec.ts

@@ -1,6 +1,6 @@
 import { DocumentNode, FieldNode, FragmentDefinitionNode } from 'graphql';
 
-import { CustomFieldConfig, CustomFields } from '../common/generated-types';
+import { CustomFieldConfig, CustomFields } from '../../common/generated-types';
 
 import { addCustomFields } from './add-custom-fields';
 

+ 1 - 1
packages/admin-ui/src/app/data/add-custom-fields.ts → packages/admin-ui/src/app/data/utils/add-custom-fields.ts

@@ -7,7 +7,7 @@ import {
     SelectionNode,
 } from 'graphql';
 
-import { CustomFields } from '../common/generated-types';
+import { CustomFields } from '../../common/generated-types';
 
 /**
  * Given a GraphQL AST (DocumentNode), this function looks for fragment definitions and adds and configured

+ 91 - 0
packages/admin-ui/src/app/data/utils/remove-readonly-custom-fields.spec.ts

@@ -0,0 +1,91 @@
+import { CustomFieldConfig, LanguageCode } from '../../common/generated-types';
+
+import { removeReadonlyCustomFields } from './remove-readonly-custom-fields';
+
+describe('removeReadonlyCustomFields', () => {
+    it('readonly field and writable field', () => {
+        const config: CustomFieldConfig[] = [
+            { name: 'weight', type: 'int' },
+            { name: 'rating', type: 'float', readonly: true },
+        ];
+        const entity = {
+            id: 1,
+            name: 'test',
+            customFields: {
+                weight: 500,
+                rating: 123,
+            },
+        };
+
+        const result = removeReadonlyCustomFields(entity, config);
+        expect(result).toEqual({
+            id: 1,
+            name: 'test',
+            customFields: {
+                weight: 500,
+            },
+        } as any);
+    });
+
+    it('single readonly field', () => {
+        const config: CustomFieldConfig[] = [{ name: 'rating', type: 'float', readonly: true }];
+        const entity = {
+            id: 1,
+            name: 'test',
+            customFields: {
+                rating: 123,
+            },
+        };
+
+        const result = removeReadonlyCustomFields(entity, config);
+        expect(result).toEqual({
+            id: 1,
+            name: 'test',
+            customFields: {},
+        } as any);
+    });
+
+    it('readonly field in translation', () => {
+        const config: CustomFieldConfig[] = [{ name: 'alias', type: 'localeString', readonly: true }];
+        const entity = {
+            id: 1,
+            name: 'test',
+            translations: [{ id: 1, languageCode: LanguageCode.en, customFields: { alias: 'testy' } }],
+        };
+
+        const result = removeReadonlyCustomFields(entity, config);
+        expect(result).toEqual({
+            id: 1,
+            name: 'test',
+            translations: [{ id: 1, languageCode: LanguageCode.en, customFields: {} }],
+        } as any);
+    });
+
+    it('wrapped in an input object', () => {
+        const config: CustomFieldConfig[] = [
+            { name: 'weight', type: 'int' },
+            { name: 'rating', type: 'float', readonly: true },
+        ];
+        const entity = {
+            input: {
+                id: 1,
+                name: 'test',
+                customFields: {
+                    weight: 500,
+                    rating: 123,
+                },
+            },
+        };
+
+        const result = removeReadonlyCustomFields(entity, config);
+        expect(result).toEqual({
+            input: {
+                id: 1,
+                name: 'test',
+                customFields: {
+                    weight: 500,
+                },
+            },
+        } as any);
+    });
+});

+ 86 - 0
packages/admin-ui/src/app/data/utils/remove-readonly-custom-fields.ts

@@ -0,0 +1,86 @@
+import { CustomFieldConfig } from '@vendure/common/lib/generated-types';
+import { DocumentNode, getOperationAST, NamedTypeNode, TypeNode } from 'graphql';
+import { simpleDeepClone } from 'shared/simple-deep-clone';
+
+const CREATE_ENTITY_REGEX = /Create([A-Za-z]+)Input/;
+const UPDATE_ENTITY_REGEX = /Update([A-Za-z]+)Input/;
+
+/**
+ * Checks the current documentNode for an operation with a variable named "Create<Entity>Input" or "Update<Entity>Input"
+ * and if a match is found, returns the <Entity> name.
+ */
+export function isEntityCreateOrUpdateMutation(documentNode: DocumentNode): string | undefined {
+    const operationDef = getOperationAST(documentNode, null);
+    if (operationDef && operationDef.variableDefinitions) {
+        for (const variableDef of operationDef.variableDefinitions) {
+            const namedType = extractInputType(variableDef.type);
+            const inputTypeName = namedType.name.value;
+
+            const createMatch = inputTypeName.match(CREATE_ENTITY_REGEX);
+            if (createMatch) {
+                return createMatch[1];
+            }
+            const updateMatch = inputTypeName.match(UPDATE_ENTITY_REGEX);
+            if (updateMatch) {
+                return updateMatch[1];
+            }
+        }
+    }
+}
+
+function extractInputType(type: TypeNode): NamedTypeNode {
+    if (type.kind === 'NonNullType') {
+        return extractInputType(type.type);
+    }
+    if (type.kind === 'ListType') {
+        return extractInputType(type.type);
+    }
+    return type;
+}
+
+/**
+ * Removes any `readonly` custom fields from an entity (including its translations).
+ * To be used before submitting the entity for a create or update request.
+ */
+export function removeReadonlyCustomFields<T extends any = any>(
+    variables: T,
+    customFieldConfig: CustomFieldConfig[],
+): T {
+    const clone = simpleDeepClone(variables as any);
+    if (clone.input) {
+        removeReadonly(clone.input, customFieldConfig);
+    }
+    return removeReadonly(clone, customFieldConfig);
+}
+
+function removeReadonly(input: any, customFieldConfig: CustomFieldConfig[]) {
+    for (const field of customFieldConfig) {
+        if (field.readonly) {
+            if (field.type === 'localeString') {
+                if (hasTranslations(input)) {
+                    for (const translation of input.translations) {
+                        if (
+                            hasCustomFields(translation) &&
+                            translation.customFields[field.name] !== undefined
+                        ) {
+                            delete translation.customFields[field.name];
+                        }
+                    }
+                }
+            } else {
+                if (hasCustomFields(input) && input.customFields[field.name] !== undefined) {
+                    delete input.customFields[field.name];
+                }
+            }
+        }
+    }
+    return input;
+}
+
+function hasCustomFields(input: any): input is { customFields: { [key: string]: any } } {
+    return input != null && input.hasOwnProperty('customFields');
+}
+
+function hasTranslations(input: any): input is { translations: any[] } {
+    return input != null && input.hasOwnProperty('translations');
+}

+ 5 - 5
packages/admin-ui/src/app/shared/components/custom-field-control/custom-field-control.component.html

@@ -26,13 +26,13 @@
         [id]="customField.name"
         [pattern]="customField.pattern"
         [formControl]="formGroup.get(customField.name)"
-        [readonly]="readonly"
+        [readonly]="readonly || customField.readonly"
     />
     <select
         *ngIf="isSelectInput"
         clrSelect
         [formControl]="formGroup.get(customField.name)"
-        [disabled]="readonly"
+        [disabled]="readonly || customField.readonly"
     >
         <option *ngFor="let option of stringOptions" [value]="option.value">
             {{ getLabel(option.value, option.label) }}
@@ -46,7 +46,7 @@
         [max]="max"
         [step]="step"
         [formControl]="formGroup.get(customField.name)"
-        [readonly]="readonly"
+        [readonly]="readonly || customField.readonly"
     />
     <clr-toggle-wrapper *ngIf="customField.type === 'boolean'">
         <input
@@ -54,7 +54,7 @@
             clrToggle
             [id]="customField.name"
             [formControl]="formGroup.get(customField.name)"
-            [readonly]="readonly"
+            [readonly]="readonly || customField.readonly"
         />
     </clr-toggle-wrapper>
     <vdr-datetime-picker
@@ -63,7 +63,7 @@
         [formControl]="formGroup.get(customField.name)"
         [min]="min"
         [max]="max"
-        [readonly]="readonly"
+        [readonly]="readonly || customField.readonly"
     >
 
     </vdr-datetime-picker>

+ 8 - 3
packages/dev-server/dev-config.ts

@@ -36,7 +36,12 @@ export const devConfig: VendureConfig = {
     paymentOptions: {
         paymentMethodHandlers: [examplePaymentHandler],
     },
-    customFields: {},
+    customFields: {
+        Product: [
+            { name: 'rating', type: 'float', readonly: true },
+            { name: 'markup', type: 'float', internal: true },
+        ],
+    },
     logger: new DefaultLogger({ level: LogLevel.Info }),
     importExportOptions: {
         importAssetsDir: path.join(__dirname, 'import-assets'),
@@ -79,7 +84,7 @@ function getDbConfig(): ConnectionOptions {
         case 'postgres':
             console.log('Using postgres connection');
             return {
-                synchronize: false,
+                synchronize: true,
                 type: 'postgres',
                 host: '127.0.0.1',
                 port: 5432,
@@ -106,7 +111,7 @@ function getDbConfig(): ConnectionOptions {
         default:
             console.log('Using mysql connection');
             return {
-                synchronize: false,
+                synchronize: true,
                 type: 'mysql',
                 host: '192.168.99.100',
                 port: 3306,