Quellcode durchsuchen

feat(admin-ui): Implement list types for custom fields

Relates to #416
Michael Bromley vor 5 Jahren
Ursprung
Commit
e72f0b3ef1
26 geänderte Dateien mit 241 neuen und 142 gelöschten Zeilen
  1. 9 9
      packages/admin-ui/i18n-coverage.json
  2. 13 6
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  3. 3 2
      packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts
  4. 9 9
      packages/admin-ui/src/lib/core/src/common/utilities/create-updated-translatable.spec.ts
  5. 15 9
      packages/admin-ui/src/lib/core/src/common/utilities/interpolate-description.spec.ts
  6. 2 2
      packages/admin-ui/src/lib/core/src/common/utilities/interpolate-description.ts
  7. 1 0
      packages/admin-ui/src/lib/core/src/data/definitions/settings-definitions.ts
  8. 5 2
      packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.spec.ts
  9. 8 6
      packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.spec.ts
  10. 1 1
      packages/admin-ui/src/lib/core/src/providers/custom-field-component/custom-field-component.service.ts
  11. 11 3
      packages/admin-ui/src/lib/core/src/shared/components/custom-field-control/custom-field-control.component.html
  12. 14 24
      packages/admin-ui/src/lib/core/src/shared/components/custom-field-control/custom-field-control.component.ts
  13. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/datetime-picker/datetime-picker.component.ts
  14. 1 1
      packages/admin-ui/src/lib/core/src/shared/components/datetime-picker/datetime-picker.service.ts
  15. 3 1
      packages/admin-ui/src/lib/core/src/shared/components/dropdown/dropdown-menu.component.ts
  16. 1 1
      packages/admin-ui/src/lib/core/src/shared/directives/disabled.directive.ts
  17. 1 1
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/boolean-form-input/boolean-form-input.component.html
  18. 2 2
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.html
  19. 20 8
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.scss
  20. 108 53
      packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.ts
  21. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/de.json
  22. 3 1
      packages/admin-ui/src/lib/static/i18n-messages/en.json
  23. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/es.json
  24. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/pl.json
  25. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json
  26. 2 0
      packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

+ 9 - 9
packages/admin-ui/i18n-coverage.json

@@ -1,34 +1,34 @@
 {
-  "generatedOn": "2020-07-14T09:36:35.279Z",
-  "lastCommit": "49c2ad4d53c15d4e25ba7795183084f60194d653",
+  "generatedOn": "2020-07-28T15:41:10.262Z",
+  "lastCommit": "2f4760e74e7b14caf171772e03c3095212eb17bc",
   "translationStatus": {
     "de": {
-      "tokenCount": 659,
+      "tokenCount": 661,
       "translatedCount": 609,
       "percentage": 92
     },
     "en": {
-      "tokenCount": 659,
-      "translatedCount": 659,
+      "tokenCount": 661,
+      "translatedCount": 660,
       "percentage": 100
     },
     "es": {
-      "tokenCount": 659,
+      "tokenCount": 661,
       "translatedCount": 467,
       "percentage": 71
     },
     "pl": {
-      "tokenCount": 659,
+      "tokenCount": 661,
       "translatedCount": 566,
       "percentage": 86
     },
     "zh_Hans": {
-      "tokenCount": 659,
+      "tokenCount": 661,
       "translatedCount": 550,
       "percentage": 83
     },
     "zh_Hant": {
-      "tokenCount": 659,
+      "tokenCount": 661,
       "translatedCount": 550,
       "percentage": 83
     }

+ 13 - 6
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -187,6 +187,7 @@ export type BooleanCustomFieldConfig = CustomField & {
    __typename?: 'BooleanCustomFieldConfig';
   name: Scalars['String'];
   type: Scalars['String'];
+  list: Scalars['Boolean'];
   label?: Maybe<Array<LocalizedString>>;
   description?: Maybe<Array<LocalizedString>>;
   readonly?: Maybe<Scalars['Boolean']>;
@@ -1053,6 +1054,7 @@ export type CustomerSortParameter = {
 export type CustomField = {
   name: Scalars['String'];
   type: Scalars['String'];
+  list: Scalars['Boolean'];
   label?: Maybe<Array<LocalizedString>>;
   description?: Maybe<Array<LocalizedString>>;
   readonly?: Maybe<Scalars['Boolean']>;
@@ -1100,6 +1102,7 @@ export type DateTimeCustomFieldConfig = CustomField & {
    __typename?: 'DateTimeCustomFieldConfig';
   name: Scalars['String'];
   type: Scalars['String'];
+  list: Scalars['Boolean'];
   label?: Maybe<Array<LocalizedString>>;
   description?: Maybe<Array<LocalizedString>>;
   readonly?: Maybe<Scalars['Boolean']>;
@@ -1225,6 +1228,7 @@ export type FloatCustomFieldConfig = CustomField & {
    __typename?: 'FloatCustomFieldConfig';
   name: Scalars['String'];
   type: Scalars['String'];
+  list: Scalars['Boolean'];
   label?: Maybe<Array<LocalizedString>>;
   description?: Maybe<Array<LocalizedString>>;
   readonly?: Maybe<Scalars['Boolean']>;
@@ -1334,6 +1338,7 @@ export type IntCustomFieldConfig = CustomField & {
    __typename?: 'IntCustomFieldConfig';
   name: Scalars['String'];
   type: Scalars['String'];
+  list: Scalars['Boolean'];
   label?: Maybe<Array<LocalizedString>>;
   description?: Maybe<Array<LocalizedString>>;
   readonly?: Maybe<Scalars['Boolean']>;
@@ -1744,6 +1749,7 @@ export type LocaleStringCustomFieldConfig = CustomField & {
    __typename?: 'LocaleStringCustomFieldConfig';
   name: Scalars['String'];
   type: Scalars['String'];
+  list: Scalars['Boolean'];
   label?: Maybe<Array<LocalizedString>>;
   description?: Maybe<Array<LocalizedString>>;
   readonly?: Maybe<Scalars['Boolean']>;
@@ -3503,6 +3509,7 @@ export type StringCustomFieldConfig = CustomField & {
    __typename?: 'StringCustomFieldConfig';
   name: Scalars['String'];
   type: Scalars['String'];
+  list: Scalars['Boolean'];
   length?: Maybe<Scalars['Int']>;
   label?: Maybe<Array<LocalizedString>>;
   description?: Maybe<Array<LocalizedString>>;
@@ -6187,7 +6194,7 @@ export type UpdateGlobalSettingsMutation = (
 
 type CustomFieldConfig_StringCustomFieldConfig_Fragment = (
   { __typename?: 'StringCustomFieldConfig' }
-  & Pick<StringCustomFieldConfig, 'name' | 'type' | 'readonly'>
+  & Pick<StringCustomFieldConfig, 'name' | 'type' | 'list' | 'readonly'>
   & { description?: Maybe<Array<(
     { __typename?: 'LocalizedString' }
     & Pick<LocalizedString, 'languageCode' | 'value'>
@@ -6199,7 +6206,7 @@ type CustomFieldConfig_StringCustomFieldConfig_Fragment = (
 
 type CustomFieldConfig_LocaleStringCustomFieldConfig_Fragment = (
   { __typename?: 'LocaleStringCustomFieldConfig' }
-  & Pick<LocaleStringCustomFieldConfig, 'name' | 'type' | 'readonly'>
+  & Pick<LocaleStringCustomFieldConfig, 'name' | 'type' | 'list' | 'readonly'>
   & { description?: Maybe<Array<(
     { __typename?: 'LocalizedString' }
     & Pick<LocalizedString, 'languageCode' | 'value'>
@@ -6211,7 +6218,7 @@ type CustomFieldConfig_LocaleStringCustomFieldConfig_Fragment = (
 
 type CustomFieldConfig_IntCustomFieldConfig_Fragment = (
   { __typename?: 'IntCustomFieldConfig' }
-  & Pick<IntCustomFieldConfig, 'name' | 'type' | 'readonly'>
+  & Pick<IntCustomFieldConfig, 'name' | 'type' | 'list' | 'readonly'>
   & { description?: Maybe<Array<(
     { __typename?: 'LocalizedString' }
     & Pick<LocalizedString, 'languageCode' | 'value'>
@@ -6223,7 +6230,7 @@ type CustomFieldConfig_IntCustomFieldConfig_Fragment = (
 
 type CustomFieldConfig_FloatCustomFieldConfig_Fragment = (
   { __typename?: 'FloatCustomFieldConfig' }
-  & Pick<FloatCustomFieldConfig, 'name' | 'type' | 'readonly'>
+  & Pick<FloatCustomFieldConfig, 'name' | 'type' | 'list' | 'readonly'>
   & { description?: Maybe<Array<(
     { __typename?: 'LocalizedString' }
     & Pick<LocalizedString, 'languageCode' | 'value'>
@@ -6235,7 +6242,7 @@ type CustomFieldConfig_FloatCustomFieldConfig_Fragment = (
 
 type CustomFieldConfig_BooleanCustomFieldConfig_Fragment = (
   { __typename?: 'BooleanCustomFieldConfig' }
-  & Pick<BooleanCustomFieldConfig, 'name' | 'type' | 'readonly'>
+  & Pick<BooleanCustomFieldConfig, 'name' | 'type' | 'list' | 'readonly'>
   & { description?: Maybe<Array<(
     { __typename?: 'LocalizedString' }
     & Pick<LocalizedString, 'languageCode' | 'value'>
@@ -6247,7 +6254,7 @@ type CustomFieldConfig_BooleanCustomFieldConfig_Fragment = (
 
 type CustomFieldConfig_DateTimeCustomFieldConfig_Fragment = (
   { __typename?: 'DateTimeCustomFieldConfig' }
-  & Pick<DateTimeCustomFieldConfig, 'name' | 'type' | 'readonly'>
+  & Pick<DateTimeCustomFieldConfig, 'name' | 'type' | 'list' | 'readonly'>
   & { description?: Maybe<Array<(
     { __typename?: 'LocalizedString' }
     & Pick<LocalizedString, 'languageCode' | 'value'>

+ 3 - 2
packages/admin-ui/src/lib/core/src/common/utilities/configurable-operation-utils.ts

@@ -1,4 +1,4 @@
-import { ConfigArgType } from '@vendure/common/lib/shared-types';
+import { ConfigArgType, CustomFieldType } from '@vendure/common/lib/shared-types';
 import { assertNever } from '@vendure/common/lib/shared-utils';
 
 import { ConfigArgDefinition } from '../generated-types';
@@ -27,7 +27,7 @@ export function getDefaultConfigArgValue(arg: ConfigArgDefinition): any {
     return arg.list ? [] : getDefaultConfigArgSingleValue(arg.type as ConfigArgType);
 }
 
-export function getDefaultConfigArgSingleValue(type: ConfigArgType): any {
+export function getDefaultConfigArgSingleValue(type: ConfigArgType | CustomFieldType): any {
     switch (type) {
         case 'boolean':
             return 'false';
@@ -37,6 +37,7 @@ export function getDefaultConfigArgSingleValue(type: ConfigArgType): any {
         case 'ID':
             return '';
         case 'string':
+        case 'localeString':
             return '';
         case 'datetime':
             return new Date();

+ 9 - 9
packages/admin-ui/src/lib/core/src/common/utilities/create-updated-translatable.spec.ts

@@ -113,8 +113,8 @@ describe('createUpdatedTranslatable()', () => {
 
     it('updates custom fields correctly', () => {
         const customFieldConfig: CustomFieldConfig[] = [
-            { name: 'available', type: 'boolean' },
-            { name: 'shortName', type: 'localeString' },
+            { name: 'available', type: 'boolean', list: false },
+            { name: 'shortName', type: 'localeString', list: false },
         ];
         product.customFields = {
             available: true,
@@ -151,8 +151,8 @@ describe('createUpdatedTranslatable()', () => {
 
     it('updates custom fields when none initially exists', () => {
         const customFieldConfig: CustomFieldConfig[] = [
-            { name: 'available', type: 'boolean' },
-            { name: 'shortName', type: 'localeString' },
+            { name: 'available', type: 'boolean', list: false },
+            { name: 'shortName', type: 'localeString', list: false },
         ];
 
         const formValue = {
@@ -184,11 +184,11 @@ describe('createUpdatedTranslatable()', () => {
 
     it('coerces empty customFields to correct type', () => {
         const customFieldConfig: CustomFieldConfig[] = [
-            { name: 'a', type: 'boolean' },
-            { name: 'b', type: 'int' },
-            { name: 'c', type: 'float' },
-            { name: 'd', type: 'datetime' },
-            { name: 'e', type: 'string' },
+            { name: 'a', type: 'boolean', list: false },
+            { name: 'b', type: 'int', list: false },
+            { name: 'c', type: 'float', list: false },
+            { name: 'd', type: 'datetime', list: false },
+            { name: 'e', type: 'string', list: false },
         ];
 
         const formValue = {

+ 15 - 9
packages/admin-ui/src/lib/core/src/common/utilities/interpolate-description.spec.ts

@@ -5,7 +5,7 @@ import { interpolateDescription } from './interpolate-description';
 describe('interpolateDescription()', () => {
     it('works for single argument', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'foo', type: 'string' }],
+            args: [{ name: 'foo', type: 'string', list: false }],
             description: 'The value is { foo }',
         };
         const result = interpolateDescription(operation as any, { foo: 'val' });
@@ -15,7 +15,10 @@ describe('interpolateDescription()', () => {
 
     it('works for multiple arguments', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'foo', type: 'string' }, { name: 'bar', type: 'string' }],
+            args: [
+                { name: 'foo', type: 'string', list: false },
+                { name: 'bar', type: 'string', list: false },
+            ],
             description: 'The value is { foo } and { bar }',
         };
         const result = interpolateDescription(operation as any, { foo: 'val1', bar: 'val2' });
@@ -25,7 +28,7 @@ describe('interpolateDescription()', () => {
 
     it('is case-insensitive', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'foo', type: 'string' }],
+            args: [{ name: 'foo', type: 'string', list: false }],
             description: 'The value is { FOo }',
         };
         const result = interpolateDescription(operation as any, { foo: 'val' });
@@ -35,7 +38,10 @@ describe('interpolateDescription()', () => {
 
     it('ignores whitespaces in interpolation', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'foo', type: 'string' }, { name: 'bar', type: 'string' }],
+            args: [
+                { name: 'foo', type: 'string', list: false },
+                { name: 'bar', type: 'string', list: false },
+            ],
             description: 'The value is {foo} and {      bar    }',
         };
         const result = interpolateDescription(operation as any, { foo: 'val1', bar: 'val2' });
@@ -43,9 +49,9 @@ describe('interpolateDescription()', () => {
         expect(result).toBe('The value is val1 and val2');
     });
 
-    it('formats money as a decimal', () => {
+    it('formats currency-form-input value as a decimal', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'price', type: 'int', config: { inputType: 'money' } }],
+            args: [{ name: 'price', type: 'int', list: false, ui: { component: 'currency-form-input' } }],
             description: 'The price is { price }',
         };
         const result = interpolateDescription(operation as any, { price: 1234 });
@@ -55,7 +61,7 @@ describe('interpolateDescription()', () => {
 
     it('formats Date object as human-readable', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'date', type: 'datetime' }],
+            args: [{ name: 'date', type: 'datetime', list: false }],
             description: 'The date is { date }',
         };
         const date = new Date('2017-09-15 00:00:00');
@@ -66,7 +72,7 @@ describe('interpolateDescription()', () => {
 
     it('formats date string object as human-readable', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'date', type: 'datetime' }],
+            args: [{ name: 'date', type: 'datetime', list: false }],
             description: 'The date is { date }',
         };
         const date = '2017-09-15';
@@ -77,7 +83,7 @@ describe('interpolateDescription()', () => {
 
     it('correctly interprets falsy-looking values', () => {
         const operation: Partial<ConfigurableOperationDefinition> = {
-            args: [{ name: 'foo', type: 'int' }],
+            args: [{ name: 'foo', type: 'int', list: false }],
             description: 'The value is { foo }',
         };
         const result = interpolateDescription(operation as any, { foo: 0 });

+ 2 - 2
packages/admin-ui/src/lib/core/src/common/utilities/interpolate-description.ts

@@ -19,9 +19,9 @@ export function interpolateDescription(
         }
         let formatted = value;
         const argDef = operation.args.find(arg => arg.name === normalizedArgName);
-        /*if (argDef && argDef.type === 'int' && argDef.config && argDef.config.inputType === 'money') {
+        if (argDef && argDef.type === 'int' && argDef.ui && argDef.ui.component === 'currency-form-input') {
             formatted = value / 100;
-        }*/
+        }
         if (argDef && argDef.type === 'datetime' && value instanceof Date) {
             formatted = value.toLocaleDateString();
         }

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

@@ -442,6 +442,7 @@ export const CUSTOM_FIELD_CONFIG_FRAGMENT = gql`
     fragment CustomFieldConfig on CustomField {
         name
         type
+        list
         description {
             languageCode
             value

+ 5 - 2
packages/admin-ui/src/lib/core/src/data/utils/add-custom-fields.spec.ts

@@ -144,7 +144,10 @@ describe('addCustomFields()', () => {
 
     it('Adds customFields to Product fragment', () => {
         const customFieldsConfig: Partial<CustomFields> = {
-            Product: [{ name: 'custom1', type: 'string' }, { name: 'custom2', type: 'boolean' }],
+            Product: [
+                { name: 'custom1', type: 'string', list: false },
+                { name: 'custom2', type: 'boolean', list: false },
+            ],
         };
 
         const result = addCustomFields(documentNode, customFieldsConfig as CustomFields);
@@ -158,7 +161,7 @@ describe('addCustomFields()', () => {
 
     it('Adds customFields to Product translations', () => {
         const customFieldsConfig: Partial<CustomFields> = {
-            Product: [{ name: 'customLocaleString', type: 'localeString' }],
+            Product: [{ name: 'customLocaleString', type: 'localeString', list: false }],
         };
 
         const result = addCustomFields(documentNode, customFieldsConfig as CustomFields);

+ 8 - 6
packages/admin-ui/src/lib/core/src/data/utils/remove-readonly-custom-fields.spec.ts

@@ -5,8 +5,8 @@ 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 },
+            { name: 'weight', type: 'int', list: false },
+            { name: 'rating', type: 'float', readonly: true, list: false },
         ];
         const entity = {
             id: 1,
@@ -28,7 +28,7 @@ describe('removeReadonlyCustomFields', () => {
     });
 
     it('single readonly field', () => {
-        const config: CustomFieldConfig[] = [{ name: 'rating', type: 'float', readonly: true }];
+        const config: CustomFieldConfig[] = [{ name: 'rating', type: 'float', readonly: true, list: false }];
         const entity = {
             id: 1,
             name: 'test',
@@ -46,7 +46,9 @@ describe('removeReadonlyCustomFields', () => {
     });
 
     it('readonly field in translation', () => {
-        const config: CustomFieldConfig[] = [{ name: 'alias', type: 'localeString', readonly: true }];
+        const config: CustomFieldConfig[] = [
+            { name: 'alias', type: 'localeString', readonly: true, list: false },
+        ];
         const entity = {
             id: 1,
             name: 'test',
@@ -63,8 +65,8 @@ describe('removeReadonlyCustomFields', () => {
 
     it('wrapped in an input object', () => {
         const config: CustomFieldConfig[] = [
-            { name: 'weight', type: 'int' },
-            { name: 'rating', type: 'float', readonly: true },
+            { name: 'weight', type: 'int', list: false },
+            { name: 'rating', type: 'float', readonly: true, list: false },
         ];
         const entity = {
             input: {

+ 1 - 1
packages/admin-ui/src/lib/core/src/providers/custom-field-component/custom-field-component.service.ts

@@ -2,7 +2,6 @@ import {
     APP_INITIALIZER,
     ComponentFactory,
     ComponentFactoryResolver,
-    ComponentRef,
     Injectable,
     Injector,
     Provider,
@@ -11,6 +10,7 @@ import { FormControl } from '@angular/forms';
 import { Type } from '@vendure/common/lib/shared-types';
 
 import { CustomFields, CustomFieldsFragment } from '../../common/generated-types';
+
 export type CustomFieldConfigType = CustomFieldsFragment;
 
 export interface CustomFieldControl {

+ 11 - 3
packages/admin-ui/src/lib/core/src/shared/components/custom-field-control/custom-field-control.component.html

@@ -20,7 +20,16 @@
     </ng-template>
 </vdr-form-field>
 <ng-template #inputs>
-    <input
+    <ng-container [formGroup]="formGroup">
+        <vdr-dynamic-form-input
+            [formControlName]="customField.name"
+            [readonly]="readonly || customField.readonly"
+            [control]="formGroup.get(customField.name)"
+            [def]="customField"
+        >
+        </vdr-dynamic-form-input>
+    </ng-container>
+    <!--<input
         *ngIf="isTextInput"
         type="text"
         [id]="customField.name"
@@ -65,6 +74,5 @@
         [max]="max"
         [readonly]="readonly || customField.readonly"
     >
-
-    </vdr-datetime-picker>
+    </vdr-datetime-picker>-->
 </ng-template>

+ 14 - 24
packages/admin-ui/src/lib/core/src/shared/components/custom-field-control/custom-field-control.component.ts

@@ -1,9 +1,21 @@
-import { AfterViewInit, Component, ComponentFactory, Input, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
+import {
+    AfterViewInit,
+    Component,
+    ComponentFactory,
+    Input,
+    OnInit,
+    ViewChild,
+    ViewContainerRef,
+} from '@angular/core';
 import { FormControl, FormGroup } from '@angular/forms';
 
 import { CustomFieldsFragment } from '../../../common/generated-types';
 import { DataService } from '../../../data/providers/data.service';
-import { CustomFieldComponentService, CustomFieldControl, CustomFieldEntityName } from '../../../providers/custom-field-component/custom-field-component.service';
+import {
+    CustomFieldComponentService,
+    CustomFieldControl,
+    CustomFieldEntityName,
+} from '../../../providers/custom-field-component/custom-field-component.service';
 
 /**
  * This component renders the appropriate type of form input control based
@@ -51,28 +63,6 @@ export class CustomFieldControlComponent implements OnInit, AfterViewInit {
         }
     }
 
-    get isTextInput(): boolean {
-        if (this.customField.__typename === 'StringCustomFieldConfig') {
-            return !this.customField.options;
-        } else {
-            return this.customField.__typename === 'LocaleStringCustomFieldConfig';
-        }
-    }
-
-    get isSelectInput(): boolean {
-        if (this.customField.__typename === 'StringCustomFieldConfig') {
-            return !!this.customField.options;
-        }
-        return false;
-    }
-
-    get stringOptions() {
-        if (this.customField.__typename === 'StringCustomFieldConfig') {
-            return this.customField.options || [];
-        }
-        return [];
-    }
-
     get min(): string | number | undefined | null {
         switch (this.customField.__typename) {
             case 'IntCustomFieldConfig':

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/datetime-picker/datetime-picker.component.ts

@@ -11,7 +11,7 @@ import {
 } from '@angular/core';
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 import { Observable, Subscription } from 'rxjs';
-import { map } from 'rxjs/operators';
+import { map, tap } from 'rxjs/operators';
 
 import { DropdownComponent } from '../dropdown/dropdown.component';
 

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/datetime-picker/datetime-picker.service.ts

@@ -46,7 +46,7 @@ export class DatetimePickerService {
     selectDatetime(date: Date | string | dayjs.Dayjs | null) {
         let viewingValue: dayjs.Dayjs;
         let selectedValue: dayjs.Dayjs | null = null;
-        if (date == null) {
+        if (date == null || date === '') {
             viewingValue = dayjs();
         } else {
             viewingValue = dayjs(date);

+ 3 - 1
packages/admin-ui/src/lib/core/src/shared/components/dropdown/dropdown-menu.component.ts

@@ -86,7 +86,9 @@ export class DropdownMenuComponent implements AfterViewInit, OnInit, OnDestroy {
     }
 
     ngOnDestroy(): void {
-        this.overlayRef.dispose();
+        if (this.overlayRef) {
+            this.overlayRef.dispose();
+        }
         if (this.backdropClickSub) {
             this.backdropClickSub.unsubscribe();
         }

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/directives/disabled.directive.ts

@@ -13,7 +13,7 @@ export class DisabledDirective {
         if (!this.formControlName || !this.formControlName.control) {
             return;
         }
-        if (val === false) {
+        if (!!val === false) {
             this.formControlName.control.enable({ emitEvent: false });
         } else {
             this.formControlName.control.disable({ emitEvent: false });

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

@@ -3,6 +3,6 @@
         type="checkbox"
         clrCheckbox
         [formControl]="formControl"
-        [vdrDisabled]="readonly"
+        [vdrDisabled]="!!readonly"
     />
 </clr-checkbox-wrapper>

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

@@ -5,7 +5,7 @@
     <div class="list-container" cdkDropList (cdkDropListDropped)="moveListItem($event)">
         <div class="list-item-row" *ngFor="let item of listItems; trackBy: trackById" cdkDrag [cdkDragData]="item">
             <ng-container #listItem></ng-container>
-            <button class="btn btn-link btn-sm btn-warning" (click)="removeListItem(item)">
+            <button class="btn btn-link btn-sm btn-warning" (click)="removeListItem(item)" [title]="'common.remove-item-from-list' | translate">
                 <clr-icon shape="times"></clr-icon>
             </button>
             <div class="flex-spacer"></div>
@@ -14,7 +14,7 @@
             </div>
         </div>
         <button class="btn btn-secondary btn-sm" (click)="addListItem()">
-            <clr-icon shape="plus"></clr-icon>
+            <clr-icon shape="plus"></clr-icon> {{ 'common.add-item-to-list' | translate }}
         </button>
     </div>
 </ng-template>

+ 20 - 8
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/dynamic-form-input/dynamic-form-input.component.scss

@@ -1,27 +1,39 @@
-@import "variables";
+@import 'variables';
+
+.list-container {
+    border: 1px solid $color-grey-300;
+    border-radius: 3px;
+    padding: 12px;
+}
 
 .list-item-row {
+    font-size: 13px;
     display: flex;
     align-items: center;
+    margin: 3px 0;
 }
+
 .drag-placeholder {
-    min-height: 120px;
-    background-color: $color-grey-300;
     transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
 }
 
 .cdk-drag-preview {
-    box-sizing: border-box;
+    font-size: 13px;
+    background-color: $color-grey-100;
+    opacity: 0.8;
     border-radius: 4px;
-    box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
-    0 8px 10px 1px rgba(0, 0, 0, 0.14),
-    0 3px 14px 2px rgba(0, 0, 0, 0.12);
+    box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14),
+        0 3px 14px 2px rgba(0, 0, 0, 0.12);
 }
 
 .cdk-drag-placeholder {
-    opacity: 0;
+    opacity: 0.1;
 }
 
 .cdk-drag-animating {
     transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
 }
+
+.cdk-drop-list-dragging .list-item-row:not(.cdk-drag-placeholder) {
+    transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
+}

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

@@ -20,13 +20,15 @@ import {
     ViewContainerRef,
 } from '@angular/core';
 import { ControlValueAccessor, FormArray, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
-import { getConfigArgValue, getDefaultConfigArgSingleValue } from '@vendure/admin-ui/core';
+import { CustomFieldConfig, getConfigArgValue, getDefaultConfigArgSingleValue } from '@vendure/admin-ui/core';
+import { omit } from '@vendure/common/lib/omit';
 import { ConfigArgType } from '@vendure/common/lib/shared-types';
 import { assertNever } from '@vendure/common/lib/shared-utils';
 import { simpleDeepClone } from '@vendure/common/lib/simple-deep-clone';
-import { Subject } from 'rxjs';
-import { takeUntil } from 'rxjs/operators';
+import { Subject, Subscription } from 'rxjs';
+import { switchMap, take, takeUntil } from 'rxjs/operators';
 
+import { CustomFieldType } from '../../../../../../../../common/src/shared-types';
 import { FormInputComponent, InputComponentConfig } from '../../../common/component-registry-types';
 import { ConfigArgDefinition } from '../../../common/generated-types';
 import { ComponentRegistryService } from '../../../providers/component-registry/component-registry.service';
@@ -55,7 +57,7 @@ type InputListItem = {
 })
 export class DynamicFormInputComponent
     implements OnInit, OnChanges, AfterViewInit, OnDestroy, ControlValueAccessor {
-    @Input() def: ConfigArgDefinition;
+    @Input() def: ConfigArgDefinition | CustomFieldConfig;
     @Input() readonly: boolean;
     @Input() control: FormControl;
     @ViewChild('single', { read: ViewContainerRef }) singleViewContainer: ViewContainerRef;
@@ -68,6 +70,7 @@ export class DynamicFormInputComponent
     private componentType: Type<FormInputComponent>;
     private onChange: (val: any) => void;
     private onTouch: () => void;
+    private renderList$ = new Subject();
     private destroy$ = new Subject();
 
     constructor(
@@ -78,11 +81,21 @@ export class DynamicFormInputComponent
     ) {}
 
     ngOnInit() {
-        const componentType = this.componentRegistryService.getInputComponent(
-            this.getInputComponentConfig(this.def).component,
-        );
+        const componentId = this.getInputComponentConfig(this.def).component;
+        const componentType = this.componentRegistryService.getInputComponent(componentId);
         if (componentType) {
             this.componentType = componentType;
+        } else {
+            // tslint:disable-next-line:no-console
+            console.error(
+                `No form input component registered with the id "${componentId}". Using the default input instead.`,
+            );
+            const defaultComponentType = this.componentRegistryService.getInputComponent(
+                this.getInputComponentConfig({ ...this.def, ui: undefined } as any).component,
+            );
+            if (defaultComponentType) {
+                this.componentType = defaultComponentType;
+            }
         }
     }
 
@@ -108,44 +121,51 @@ export class DynamicFormInputComponent
                     this.control,
                 );
             } else {
-                const arrayValue = Array.isArray(this.control.value)
-                    ? this.control.value
-                    : !!this.control.value
-                    ? [this.control.value]
-                    : [];
-                this.listItems = arrayValue.map(
-                    value =>
-                        ({
-                            id: this.listId++,
-                            control: new FormControl(getConfigArgValue(value)),
-                        } as InputListItem),
-                );
-                let firstRenderHasOccurred = false;
+                let formArraySub: Subscription | undefined;
                 const renderListInputs = (viewContainerRefs: QueryList<ViewContainerRef>) => {
-                    viewContainerRefs.forEach((ref, i) => {
-                        const listItem = this.listItems[i];
-                        if (!this.listFormArray.controls.includes(listItem.control)) {
-                            this.listFormArray.push(listItem.control);
-                            listItem.componentRef = this.renderInputComponent(factory, ref, listItem.control);
+                    if (viewContainerRefs.length) {
+                        if (formArraySub) {
+                            formArraySub.unsubscribe();
                         }
-                    });
-                    firstRenderHasOccurred = true;
+                        this.listFormArray = new FormArray([]);
+                        this.listItems.forEach(i => i.componentRef?.destroy());
+                        viewContainerRefs.forEach((ref, i) => {
+                            const listItem = this.listItems?.[i];
+                            if (listItem) {
+                                this.listFormArray.push(listItem.control);
+                                listItem.componentRef = this.renderInputComponent(
+                                    factory,
+                                    ref,
+                                    listItem.control,
+                                );
+                            }
+                        });
+
+                        formArraySub = this.listFormArray.valueChanges
+                            .pipe(takeUntil(this.destroy$))
+                            .subscribe(val => {
+                                this.control.markAsTouched();
+                                this.control.markAsDirty();
+                                this.onChange(val);
+                                this.control.patchValue(val, { emitEvent: false });
+                            });
+                    }
                 };
 
+                // initial render
                 this.listItemContainers.changes
-                    .pipe(takeUntil(this.destroy$))
-                    .subscribe((refs: QueryList<ViewContainerRef>) => {
-                        renderListInputs(refs);
-                    });
+                    .pipe(take(1))
+                    .subscribe(val => renderListInputs(this.listItemContainers));
 
-                this.listFormArray.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(val => {
-                    if (firstRenderHasOccurred) {
-                        this.control.markAsTouched();
-                        this.control.markAsDirty();
-                        this.onChange(val);
-                    }
-                    this.control.patchValue(val, { emitEvent: false });
-                });
+                // render on changes to the list
+                this.renderList$
+                    .pipe(
+                        switchMap(() => this.listItemContainers.changes.pipe(take(1))),
+                        takeUntil(this.destroy$),
+                    )
+                    .subscribe(() => {
+                        renderListInputs(this.listItemContainers);
+                    });
             }
         }
         setTimeout(() => this.changeDetectorRef.markForCheck());
@@ -171,7 +191,7 @@ export class DynamicFormInputComponent
 
     private updateBindings(changes: SimpleChanges, componentRef: ComponentRef<FormInputComponent>) {
         if ('def' in changes) {
-            componentRef.instance.config = this.def.ui;
+            componentRef.instance.config = this.isConfigArgDef(this.def) ? this.def.ui : this.def;
         }
         if ('readonly' in changes) {
             componentRef.instance.readonly = this.readonly;
@@ -184,23 +204,33 @@ export class DynamicFormInputComponent
     }
 
     addListItem() {
+        if (!this.listItems) {
+            this.listItems = [];
+        }
         this.listItems.push({
             id: this.listId++,
             control: new FormControl(getDefaultConfigArgSingleValue(this.def.type as ConfigArgType)),
         });
+        this.renderList$.next();
     }
 
     moveListItem(event: CdkDragDrop<InputListItem>) {
-        moveItemInArray(this.listItems, event.previousIndex, event.currentIndex);
-        this.listFormArray.removeAt(event.previousIndex);
-        this.listFormArray.insert(event.currentIndex, event.item.data.control);
+        if (this.listItems) {
+            moveItemInArray(this.listItems, event.previousIndex, event.currentIndex);
+            this.listFormArray.removeAt(event.previousIndex);
+            this.listFormArray.insert(event.currentIndex, event.item.data.control);
+            this.renderList$.next();
+        }
     }
 
     removeListItem(item: InputListItem) {
-        const index = this.listItems.findIndex(i => i === item);
-        item.componentRef?.destroy();
-        this.listFormArray.removeAt(index);
-        this.listItems = this.listItems.filter(i => i !== item);
+        if (this.listItems) {
+            const index = this.listItems.findIndex(i => i === item);
+            item.componentRef?.destroy();
+            this.listFormArray.removeAt(index);
+            this.listItems = this.listItems.filter(i => i !== item);
+            this.renderList$.next();
+        }
     }
 
     private renderInputComponent(
@@ -210,7 +240,7 @@ export class DynamicFormInputComponent
     ) {
         const componentRef = viewContainerRef.createComponent(factory);
         const { instance } = componentRef;
-        instance.config = simpleDeepClone(this.def.ui);
+        instance.config = simpleDeepClone(this.isConfigArgDef(this.def) ? this.def.ui : this.def);
         instance.formControl = formControl;
         instance.readonly = this.readonly;
         componentRef.injector.get(ChangeDetectorRef).markForCheck();
@@ -226,17 +256,38 @@ export class DynamicFormInputComponent
     }
 
     writeValue(obj: any): void {
-        /* empty */
+        if (Array.isArray(obj)) {
+            if (obj.length === this.listItems.length) {
+                obj.forEach((value, index) => {
+                    const control = this.listItems[index]?.control;
+                    control.patchValue(getConfigArgValue(value), { emitEvent: false });
+                });
+            } else {
+                this.listItems = obj.map(
+                    value =>
+                        ({
+                            id: this.listId++,
+                            control: new FormControl(getConfigArgValue(value)),
+                        } as InputListItem),
+                );
+                this.renderList$.next();
+            }
+        } else {
+            this.listItems = [];
+            this.renderList$.next();
+        }
+        this.changeDetectorRef.markForCheck();
     }
 
-    private getInputComponentConfig(argDef: ConfigArgDefinition): InputComponentConfig {
-        if (argDef?.ui?.component) {
+    private getInputComponentConfig(argDef: ConfigArgDefinition | CustomFieldConfig): InputComponentConfig {
+        if (this.isConfigArgDef(argDef) && argDef?.ui?.component) {
             return argDef.ui;
         }
-        const type = argDef?.type as ConfigArgType;
+        const type = argDef?.type as ConfigArgType | CustomFieldType;
         switch (type) {
             case 'string':
-                if (argDef.ui?.options) {
+            case 'localeString':
+                if (this.isConfigArgDef(argDef) && argDef.ui?.options) {
                     return { component: 'select-form-input' };
                 } else {
                     return { component: 'text-form-input' };
@@ -254,4 +305,8 @@ export class DynamicFormInputComponent
                 assertNever(type);
         }
     }
+
+    private isConfigArgDef(def: ConfigArgDefinition | CustomFieldConfig): def is ConfigArgDefinition {
+        return (def as ConfigArgDefinition)?.__typename === 'ConfigArgDefinition';
+    }
 }

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/de.json

@@ -134,6 +134,7 @@
   "common": {
     "ID": "ID",
     "actions": "Aktionen",
+    "add-item-to-list": "",
     "add-new-variants": "{count, plural, one {1 Variante} other {{count} Varianten}} hinzufügen",
     "add-note": "",
     "available-languages": "Verfügbare Sprachen",
@@ -190,6 +191,7 @@
     "public": "Öffentlich",
     "remember-me": "Logindaten merken",
     "remove": "Entfernen",
+    "remove-item-from-list": "",
     "results-count": "{ count } {count, plural, one {Ergebnis} other {Ergebnisse}}",
     "select": "Auswählen...",
     "select-display-language": "Anzeigesprache wählen",

+ 3 - 1
packages/admin-ui/src/lib/static/i18n-messages/en.json

@@ -134,6 +134,7 @@
   "common": {
     "ID": "ID",
     "actions": "Actions",
+    "add-item-to-list": "Add item to list",
     "add-new-variants": "Add {count, plural, one {1 variant} other {{count} variants}}",
     "add-note": "Add note",
     "available-languages": "Available languages",
@@ -190,6 +191,7 @@
     "public": "Public",
     "remember-me": "Remember me",
     "remove": "Remove",
+    "remove-item-from-list": "Remove item from list",
     "results-count": "{ count } {count, plural, one {result} other {results}}",
     "select": "Select...",
     "select-display-language": "Select display language",
@@ -688,4 +690,4 @@
     "job-result": "Job result",
     "job-state": "Job state"
   }
-}
+}

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/es.json

@@ -134,6 +134,7 @@
   "common": {
     "ID": "ID",
     "actions": "Acciones",
+    "add-item-to-list": "",
     "add-new-variants": "",
     "add-note": "Añadir nota",
     "available-languages": "Idiomas disponibles",
@@ -190,6 +191,7 @@
     "public": "Público",
     "remember-me": "Recordarme",
     "remove": "Borrar",
+    "remove-item-from-list": "",
     "results-count": "",
     "select": "Seleccionar...",
     "select-display-language": "Seleccionar idioma de interfaz",

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/pl.json

@@ -134,6 +134,7 @@
   "common": {
     "ID": "ID",
     "actions": "Akcje",
+    "add-item-to-list": "",
     "add-new-variants": "Dodaj {count, plural, one {1 wariant} other {{count} wariantów}}",
     "add-note": "",
     "available-languages": "Dostępne języki",
@@ -190,6 +191,7 @@
     "public": "Publiczne",
     "remember-me": "Zapamiętaj mnie",
     "remove": "Usuń",
+    "remove-item-from-list": "",
     "results-count": "{ count } {count, plural, one {wynik} other {wyników}}",
     "select": "Wybrano...",
     "select-display-language": "Wybierz język",

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/zh_Hans.json

@@ -134,6 +134,7 @@
   "common": {
     "ID": "ID",
     "actions": "操作",
+    "add-item-to-list": "",
     "add-new-variants": "添加{count}个商品规格",
     "add-note": "",
     "available-languages": "可用语言",
@@ -190,6 +191,7 @@
     "public": "公开",
     "remember-me": "记住我",
     "remove": "删除",
+    "remove-item-from-list": "",
     "results-count": "{count, plural, 0{无} other {{count}个过滤结果}}",
     "select": "选择...",
     "select-display-language": "选择显示语言",

+ 2 - 0
packages/admin-ui/src/lib/static/i18n-messages/zh_Hant.json

@@ -134,6 +134,7 @@
   "common": {
     "ID": "ID",
     "actions": "操作",
+    "add-item-to-list": "",
     "add-new-variants": "新增{count}個商品規格",
     "add-note": "",
     "available-languages": "可用語言",
@@ -190,6 +191,7 @@
     "public": "公開",
     "remember-me": "記住登入帳號",
     "remove": "移除",
+    "remove-item-from-list": "",
     "results-count": "{count, plural, 0{無} other {{count}個篩選結果}}",
     "select": "選擇...",
     "select-display-language": "選擇顯示語言",