Browse Source

feat(admin-ui): Add json editor field input component

Michael Bromley 4 years ago
parent
commit
4297b87b62

+ 1 - 0
packages/admin-ui/package.json

@@ -43,6 +43,7 @@
     "@webcomponents/custom-elements": "^1.4.3",
     "apollo-angular": "^2.4.0",
     "apollo-upload-client": "^14.1.3",
+    "codejar": "^3.5.0",
     "core-js": "^3.9.1",
     "dayjs": "^1.10.4",
     "graphql": "15.5.1",

+ 4 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component.html

@@ -0,0 +1,4 @@
+<div #editor class="json-editor" [class.invalid]="!isValid" [style.height]="height || '300px'"></div>
+<div class="error-message">
+    <span *ngIf="errorMessage">{{ errorMessage }}</span>
+</div>

+ 44 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component.scss

@@ -0,0 +1,44 @@
+.json-editor {
+    min-height: 6rem;
+    background-color: var(--color-json-editor-background-color);
+    color: var(--color-json-editor-text);
+    border: 1px solid var(--color-component-border-200);
+    border-radius: 3px;
+    padding: 6px;
+    tab-size: 4;
+    font-family: 'Source Code Pro', 'Lucida Console', Monaco, monospace;
+    font-size: 14px;
+    font-weight: 400;
+    height: 340px;
+    letter-spacing: normal;
+    line-height: 20px;
+
+    &:focus {
+        border-color: var(--color-primary-500);
+    }
+
+    &.invalid {
+        border-color: var(--clr-forms-invalid-color);
+    }
+
+    // prettier-ignore
+    ::ng-deep {
+       .je-string { color: var(--color-json-editor-string); }
+       .je-number { color: var(--color-json-editor-number); }
+       .je-boolean { color: var(--color-json-editor-boolean); }
+       .je-null { color: var(--color-json-editor-null); }
+       .je-key { color: var(--color-json-editor-key); }
+       .je-error {
+           text-decoration-line: underline;
+           text-decoration-style: wavy;
+           text-decoration-color: var(--color-json-editor-error);
+       }
+    }
+}
+
+.error-message {
+    min-height: 1rem;
+    color: var(--color-json-editor-error);
+}
+
+

+ 129 - 0
packages/admin-ui/src/lib/core/src/shared/dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component.ts

@@ -0,0 +1,129 @@
+import {
+    AfterViewInit,
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    ElementRef,
+    OnInit,
+    ViewChild,
+} from '@angular/core';
+import { AbstractControl, FormControl, ValidationErrors, ValidatorFn } from '@angular/forms';
+import { DefaultFormComponentConfig, DefaultFormComponentId } from '@vendure/common/lib/shared-types';
+import { CodeJar } from 'codejar';
+
+import { FormInputComponent } from '../../../common/component-registry-types';
+
+export function jsonValidator(): ValidatorFn {
+    return (control: AbstractControl): ValidationErrors | null => {
+        const error: ValidationErrors = { jsonInvalid: true };
+
+        try {
+            JSON.parse(control.value);
+        } catch (e) {
+            control.setErrors(error);
+            return error;
+        }
+
+        control.setErrors(null);
+        return null;
+    };
+}
+
+@Component({
+    selector: 'vdr-json-editor-form-input',
+    templateUrl: './json-editor-form-input.component.html',
+    styleUrls: ['./json-editor-form-input.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class JsonEditorFormInputComponent implements FormInputComponent, AfterViewInit, OnInit {
+    static readonly id: DefaultFormComponentId = 'json-editor-form-input';
+    readonly: boolean;
+    formControl: FormControl;
+    config: DefaultFormComponentConfig<'json-editor-form-input'>;
+    isValid = true;
+    height: DefaultFormComponentConfig<'json-editor-form-input'>['height'];
+    errorMessage: string | undefined;
+    @ViewChild('editor') private editorElementRef: ElementRef<HTMLDivElement>;
+    jar: CodeJar;
+
+    constructor(private changeDetector: ChangeDetectorRef) {}
+
+    ngOnInit() {
+        this.formControl.addValidators(jsonValidator());
+    }
+
+    ngAfterViewInit() {
+        let lastVal = '';
+        const highlight = (editor: HTMLElement) => {
+            const code = editor.textContent ?? '';
+            if (code === lastVal) {
+                return;
+            }
+            lastVal = code;
+            this.errorMessage = this.getJsonError(code);
+            this.changeDetector.markForCheck();
+            editor.innerHTML = this.syntaxHighlight(code, this.getErrorPos(this.errorMessage));
+        };
+        this.jar = CodeJar(this.editorElementRef.nativeElement, highlight);
+        this.jar.onUpdate(value => {
+            this.formControl.setValue(value);
+            this.formControl.markAsDirty();
+            this.isValid = this.formControl.valid;
+        });
+        this.jar.updateCode(this.formControl.value);
+
+        if (this.readonly) {
+            this.editorElementRef.nativeElement.contentEditable = 'false';
+        }
+    }
+
+    private getJsonError(json: string): string | undefined {
+        try {
+            JSON.parse(json);
+        } catch (e) {
+            return e.message;
+        }
+        return;
+    }
+
+    private getErrorPos(errorMessage: string | undefined): number | undefined {
+        if (!errorMessage) {
+            return;
+        }
+        const matches = errorMessage.match(/at position (\d+)/);
+        const pos = matches?.[1];
+        return pos != null ? +pos : undefined;
+    }
+
+    private syntaxHighlight(json: string, errorPos: number | undefined) {
+        json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+        let hasMarkedError = false;
+        return json.replace(
+            /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
+            (match, ...args) => {
+                let cls = 'number';
+                if (/^"/.test(match)) {
+                    if (/:$/.test(match)) {
+                        cls = 'key';
+                    } else {
+                        cls = 'string';
+                    }
+                } else if (/true|false/.test(match)) {
+                    cls = 'boolean';
+                } else if (/null/.test(match)) {
+                    cls = 'null';
+                }
+                let errorClass = '';
+                if (errorPos && !hasMarkedError) {
+                    const length = args[0].length;
+                    const offset = args[4];
+                    if (errorPos <= length + offset) {
+                        errorClass = 'je-error';
+                        hasMarkedError = true;
+                    }
+                }
+                return '<span class="je-' + cls + ' ' + errorClass + '">' + match + '</span>';
+            },
+        );
+    }
+}

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

@@ -9,6 +9,7 @@ import {
 } from '../../providers/custom-field-component/custom-field-component.service';
 
 import { BooleanFormInputComponent } from './boolean-form-input/boolean-form-input.component';
+import { JsonEditorFormInputComponent } from './code-editor-form-input/json-editor-form-input.component';
 import { CurrencyFormInputComponent } from './currency-form-input/currency-form-input.component';
 import { CustomerGroupFormInputComponent } from './customer-group-form-input/customer-group-form-input.component';
 import { DateFormInputComponent } from './date-form-input/date-form-input.component';
@@ -36,6 +37,7 @@ export const defaultFormInputs = [
     RelationFormInputComponent,
     TextareaFormInputComponent,
     RichTextFormInputComponent,
+    JsonEditorFormInputComponent,
 ];
 
 /**

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

@@ -83,6 +83,7 @@ import { IfDefaultChannelActiveDirective } from './directives/if-default-channel
 import { IfMultichannelDirective } from './directives/if-multichannel.directive';
 import { IfPermissionsDirective } from './directives/if-permissions.directive';
 import { BooleanFormInputComponent } from './dynamic-form-inputs/boolean-form-input/boolean-form-input.component';
+import { JsonEditorFormInputComponent } from './dynamic-form-inputs/code-editor-form-input/json-editor-form-input.component';
 import { CurrencyFormInputComponent } from './dynamic-form-inputs/currency-form-input/currency-form-input.component';
 import { CustomerGroupFormInputComponent } from './dynamic-form-inputs/customer-group-form-input/customer-group-form-input.component';
 import { DateFormInputComponent } from './dynamic-form-inputs/date-form-input/date-form-input.component';
@@ -244,6 +245,7 @@ const DYNAMIC_FORM_INPUTS = [
     RelationSelectorDialogComponent,
     TextareaFormInputComponent,
     RichTextFormInputComponent,
+    JsonEditorFormInputComponent,
 ];
 
 @NgModule({

+ 9 - 0
packages/admin-ui/src/lib/static/styles/theme/dark.scss

@@ -36,6 +36,15 @@
     --color-form-input-bg: hsl(212, 35%, 95%);
     --color-timeline-thread: var(--color-primary-700);
 
+    --color-json-editor-background-color: var(--color-grey-600);
+    --color-json-editor-text: var(--color-grey-100);
+    --color-json-editor-string: var(--color-secondary-300);
+    --color-json-editor-number: var(--color-primary-300);
+    --color-json-editor-boolean: var(--color-primary-300);
+    --color-json-editor-null: var(--color-grey-300-grey-500);
+    --color-json-editor-key: var(--color-success-300);
+    --color-json-editor-error: var(--color-error-200);
+
     // clarity styles
     --clr-global-app-background: hsl(201, 30%, 15%);
     --clr-global-selection-color: hsl(203, 32%, 29%);

+ 8 - 0
packages/admin-ui/src/lib/static/styles/theme/default.scss

@@ -83,6 +83,14 @@
     --color-chip-error-border: var(--color-error-200);
     --color-chip-error-text: var(--color-error-600);
     --color-chip-error-bg: var(--color-error-100);
+    --color-json-editor-background-color: var(--color-grey-200);
+    --color-json-editor-text: var(--color-grey-600);
+    --color-json-editor-string: var(--color-secondary-600);
+    --color-json-editor-number: var(--color-primary-600);
+    --color-json-editor-boolean: var(--color-primary-600);
+    --color-json-editor-null: var(--color-grey-500);
+    --color-json-editor-key: var(--color-success-500);
+    --color-json-editor-error: var(--color-error-500);
 
     // Other variables
     --login-page-bg: url(data:image/gif;base64,R0lGODlhFAAhAKIAABwcHBsbGxcXFx0dHRUVFRkZGR4eHhQUFCH/C1hNUCBEYXRhWE1QPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS4zLWMwMTEgNjYuMTQ2NzI5LCAyMDEyLzA1LzAzLTEzOjQwOjAzICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgRWxlbWVudHMgMTIuMCBXaW5kb3dzIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjhBOTg1NDlCNjMwNDExRUFBNEUwQ0M2RDdENUQ3RTBFIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjhBOTg1NDlDNjMwNDExRUFBNEUwQ0M2RDdENUQ3RTBFIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6OEE5ODU0OTk2MzA0MTFFQUE0RTBDQzZEN0Q1RDdFMEUiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6OEE5ODU0OUE2MzA0MTFFQUE0RTBDQzZEN0Q1RDdFMEUiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4B//79/Pv6+fj39vX08/Lx8O/u7ezr6uno5+bl5OPi4eDf3t3c29rZ2NfW1dTT0tHQz87NzMvKycjHxsXEw8LBwL++vby7urm4t7a1tLOysbCvrq2sq6qpqKempaSjoqGgn56dnJuamZiXlpWUk5KRkI+OjYyLiomIh4aFhIOCgYB/fn18e3p5eHd2dXRzcnFwb25tbGtqaWhnZmVkY2JhYF9eXVxbWllYV1ZVVFNSUVBPTk1MS0pJSEdGRURDQkFAPz49PDs6OTg3NjU0MzIxMC8uLSwrKikoJyYlJCMiISAfHh0cGxoZGBcWFRQTEhEQDw4NDAsKCQgHBgUEAwIBAAAh+QQAAAAAACwAAAAAFAAhAAADfSi23M7igEcZOIfUSvApm1N42RAuQ0cqZ0Ri0xa8nlaltAdSY44RJsfAt3q4iLDGDFlj4Ji6RQ/6GwypqyOtwO12BQKv+EawnRq93dlwLa0NWtZpSYptniRzZfpSP9o0QBVaNHJKUHYoKkh6BnxIaoBMgnBYGAp0lgCLlgQJADs=);

+ 27 - 20
packages/common/src/shared-types.ts

@@ -132,17 +132,18 @@ export type ConfigArgType = 'string' | 'int' | 'float' | 'boolean' | 'datetime'
 export type DefaultFormComponentId =
     | 'boolean-form-input'
     | 'currency-form-input'
+    | 'customer-group-form-input'
     | 'date-form-input'
     | 'facet-value-form-input'
+    | 'json-editor-form-input'
     | 'number-form-input'
-    | 'select-form-input'
+    | 'password-form-input'
     | 'product-selector-form-input'
-    | 'customer-group-form-input'
-    | 'text-form-input'
-    | 'textarea-form-input'
+    | 'relation-form-input'
     | 'rich-text-form-input'
-    | 'password-form-input'
-    | 'relation-form-input';
+    | 'select-form-input'
+    | 'text-form-input'
+    | 'textarea-form-input';
 
 /**
  * @description
@@ -151,40 +152,46 @@ export type DefaultFormComponentId =
  * @docsCategory ConfigurableOperationDef
  */
 type DefaultFormConfigHash = {
+    'boolean-form-input': {};
+    'currency-form-input': {};
+    'customer-group-form-input': {};
     'date-form-input': { min?: string; max?: string; yearRange?: number };
+    'facet-value-form-input': {};
+    'json-editor-form-input': { height?: string };
     'number-form-input': { min?: number; max?: number; step?: number; prefix?: string; suffix?: string };
+    'password-form-input': {};
+    'product-selector-form-input': {};
+    'relation-form-input': {};
+    'rich-text-form-input': {};
     'select-form-input': {
         options?: Array<{ value: string; label?: Array<Omit<LocalizedString, '__typename'>> }>;
     };
-    'boolean-form-input': {};
-    'currency-form-input': {};
-    'facet-value-form-input': {};
-    'product-selector-form-input': {};
-    'customer-group-form-input': {};
     'text-form-input': {};
     'textarea-form-input': {
         spellcheck?: boolean;
     };
-    'rich-text-form-input': {};
-    'password-form-input': {};
-    'relation-form-input': {};
 };
 
 export type DefaultFormComponentConfig<T extends DefaultFormComponentId> = DefaultFormConfigHash[T];
 
 export type UiComponentConfig =
-    | ({ component: 'number-form-input' } & DefaultFormComponentConfig<'number-form-input'>)
-    | ({ component: 'date-form-input' } & DefaultFormComponentConfig<'date-form-input'>)
-    | ({ component: 'select-form-input' } & DefaultFormComponentConfig<'select-form-input'>)
-    | ({ component: 'text-form-input' } & DefaultFormComponentConfig<'text-form-input'>)
     | ({ component: 'boolean-form-input' } & DefaultFormComponentConfig<'boolean-form-input'>)
     | ({ component: 'currency-form-input' } & DefaultFormComponentConfig<'currency-form-input'>)
+    | ({ component: 'customer-group-form-input' } & DefaultFormComponentConfig<'customer-group-form-input'>)
+    | ({ component: 'date-form-input' } & DefaultFormComponentConfig<'date-form-input'>)
     | ({ component: 'facet-value-form-input' } & DefaultFormComponentConfig<'facet-value-form-input'>)
+    | ({ component: 'json-editor-form-input' } & DefaultFormComponentConfig<'json-editor-form-input'>)
+    | ({ component: 'number-form-input' } & DefaultFormComponentConfig<'number-form-input'>)
+    | ({ component: 'password-form-input' } & DefaultFormComponentConfig<'password-form-input'>)
     | ({
           component: 'product-selector-form-input';
       } & DefaultFormComponentConfig<'product-selector-form-input'>)
-    | ({ component: 'customer-group-form-input' } & DefaultFormComponentConfig<'customer-group-form-input'>)
-    | { component: string; [prop: string]: Json };
+    | ({ component: 'relation-form-input' } & DefaultFormComponentConfig<'relation-form-input'>)
+    | ({ component: 'rich-text-form-input' } & DefaultFormComponentConfig<'rich-text-form-input'>)
+    | ({ component: 'select-form-input' } & DefaultFormComponentConfig<'select-form-input'>)
+    | ({ component: 'text-form-input' } & DefaultFormComponentConfig<'text-form-input'>)
+    | ({ component: 'textarea-form-input' } & DefaultFormComponentConfig<'textarea-form-input'>)
+    | { component: string; [prop: string]: any };
 
 export type CustomFieldsObject = { [key: string]: any };
 

+ 17 - 2
yarn.lock

@@ -6584,6 +6584,11 @@ code-point-at@^1.0.0:
   resolved "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
   integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
 
+codejar@^3.5.0:
+  version "3.5.0"
+  resolved "https://registry.npmjs.org/codejar/-/codejar-3.5.0.tgz#be3a6a77b4c422998e56710ca854d166f8507eb2"
+  integrity sha512-uXrFZZ+yb23YY7+WtTux2Yyokt+Lty/kBnW/OhhEGp8IW8/lrJw5Gs1wwCyt2vpMfsVdudLmV5xAgYqsZY/49A==
+
 codelyzer@^6.0.0:
   version "6.0.2"
   resolved "https://registry.npmjs.org/codelyzer/-/codelyzer-6.0.2.tgz#25d72eae641e8ff13ffd7d99b27c9c7ad5d7e135"
@@ -10120,7 +10125,7 @@ ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1:
   resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
   integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
-ignore-walk@^3.0.1:
+ignore-walk@^3.0.1, ignore-walk@^3.0.3:
   version "3.0.4"
   resolved "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz#c9a09f69b7c7b479a5d74ac1a3c0d4236d2a6335"
   integrity sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==
@@ -13844,7 +13849,7 @@ npm-package-arg@8.1.5, npm-package-arg@^8.0.0, npm-package-arg@^8.0.1, npm-packa
     semver "^7.3.4"
     validate-npm-package-name "^3.0.0"
 
-npm-packlist@1.1.12, npm-packlist@^1.1.6, npm-packlist@^2.1.4:
+npm-packlist@^1.1.6:
   version "1.1.12"
   resolved "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.1.12.tgz#22bde2ebc12e72ca482abd67afc51eb49377243a"
   integrity sha512-WJKFOVMeAlsU/pjXuqVdzU0WfgtIBCupkEVwn+1Y0ERAbUfWw8R4GjgVbaKnUjRoD2FoQbHOCbOyT5Mbs9Lw4g==
@@ -13852,6 +13857,16 @@ npm-packlist@1.1.12, npm-packlist@^1.1.6, npm-packlist@^2.1.4:
     ignore-walk "^3.0.1"
     npm-bundled "^1.0.1"
 
+npm-packlist@^2.1.4:
+  version "2.2.2"
+  resolved "https://registry.npmjs.org/npm-packlist/-/npm-packlist-2.2.2.tgz#076b97293fa620f632833186a7a8f65aaa6148c8"
+  integrity sha512-Jt01acDvJRhJGthnUJVF/w6gumWOZxO7IkpY/lsX9//zqQgnF7OJaxgQXcerd4uQOLu7W5bkb4mChL9mdfm+Zg==
+  dependencies:
+    glob "^7.1.6"
+    ignore-walk "^3.0.3"
+    npm-bundled "^1.1.1"
+    npm-normalize-package-bin "^1.0.1"
+
 npm-pick-manifest@6.1.1, npm-pick-manifest@^6.0.0, npm-pick-manifest@^6.1.1:
   version "6.1.1"
   resolved "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-6.1.1.tgz#7b5484ca2c908565f43b7f27644f36bb816f5148"