Sfoglia il codice sorgente

feat(admin-ui): Add initial React support for UI extensions

Michael Bromley 2 anni fa
parent
commit
1075dd79b6

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

@@ -73,6 +73,8 @@
         "prosemirror-schema-list": "^1.3.0",
         "prosemirror-state": "^1.4.3",
         "prosemirror-tables": "^1.3.4",
+        "react": "^18.2.0",
+        "react-dom": "^18.2.0",
         "rxjs": "^7.8.1",
         "tslib": "^2.6.2",
         "zone.js": "~0.13.1"
@@ -90,6 +92,7 @@
         "@types/jasmine": "~4.3.5",
         "@types/jasminewd2": "~2.0.10",
         "@types/node": "^18.17.9",
+        "@types/react": "^18.2.21",
         "@typescript-eslint/eslint-plugin": "^5.59.2",
         "@typescript-eslint/parser": "^5.59.2",
         "@vendure/ngx-translate-extract": "^8.2.2",

+ 1 - 0
packages/admin-ui/scripts/build-public-api.js

@@ -20,6 +20,7 @@ const MODULES = [
     'order',
     'settings',
     'system',
+    'react',
 ];
 
 for (const moduleDir of MODULES) {

+ 7 - 5
packages/admin-ui/src/lib/core/src/providers/component-registry/component-registry.service.ts

@@ -1,23 +1,25 @@
-import { Injectable, Type } from '@angular/core';
+import { Injectable, InjectionToken, Type } from '@angular/core';
 
 import { FormInputComponent, InputComponentConfig } from '../../common/component-registry-types';
 
+export const INPUT_COMPONENT_OPTIONS = new InjectionToken<{ component?: any }>('INPUT_COMPONENT_OPTIONS');
+
 @Injectable({
     providedIn: 'root',
 })
 export class ComponentRegistryService {
-    private inputComponentMap = new Map<string, Type<FormInputComponent<any>>>();
+    private inputComponentMap = new Map<string, { type: Type<FormInputComponent<any>>; options?: any }>();
 
-    registerInputComponent(id: string, component: Type<FormInputComponent<any>>) {
+    registerInputComponent(id: string, component: Type<FormInputComponent<any>>, options?: any) {
         if (this.inputComponentMap.has(id)) {
             throw new Error(
                 `Cannot register an InputComponent with the id "${id}", as one with that id already exists`,
             );
         }
-        this.inputComponentMap.set(id, component);
+        this.inputComponentMap.set(id, { type: component, options });
     }
 
-    getInputComponent(id: string): Type<FormInputComponent<any>> | undefined {
+    getInputComponent(id: string): { type: Type<FormInputComponent<any>>; options?: any } | undefined {
         return this.inputComponentMap.get(id);
     }
 }

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

@@ -37,7 +37,10 @@ import { switchMap, take, takeUntil } from 'rxjs/operators';
 import { FormInputComponent } from '../../../common/component-registry-types';
 import { ConfigArgDefinition, CustomFieldConfig } from '../../../common/generated-types';
 import { getConfigArgValue } from '../../../common/utilities/configurable-operation-utils';
-import { ComponentRegistryService } from '../../../providers/component-registry/component-registry.service';
+import {
+    ComponentRegistryService,
+    INPUT_COMPONENT_OPTIONS,
+} from '../../../providers/component-registry/component-registry.service';
 
 type InputListItem = {
     id: number;
@@ -75,6 +78,7 @@ export class DynamicFormInputComponent
     private listId = 1;
     private listFormArray = new FormArray([] as Array<FormControl<any>>);
     private componentType: Type<FormInputComponent>;
+    private componentOptions?: any;
     private onChange: (val: any) => void;
     private onTouch: () => void;
     private renderList$ = new Subject<void>();
@@ -89,9 +93,10 @@ export class DynamicFormInputComponent
 
     ngOnInit() {
         const componentId = this.getInputComponentConfig(this.def).component;
-        const componentType = this.componentRegistryService.getInputComponent(componentId);
-        if (componentType) {
-            this.componentType = componentType;
+        const component = this.componentRegistryService.getInputComponent(componentId);
+        if (component) {
+            this.componentType = component.type;
+            this.componentOptions = component.options;
         } else {
             // eslint-disable-next-line no-console
             console.error(
@@ -101,7 +106,7 @@ export class DynamicFormInputComponent
                 this.getInputComponentConfig({ ...this.def, ui: undefined } as any).component,
             );
             if (defaultComponentType) {
-                this.componentType = defaultComponentType;
+                this.componentType = defaultComponentType.type;
             }
         }
     }
@@ -109,9 +114,13 @@ export class DynamicFormInputComponent
     ngAfterViewInit() {
         if (this.componentType) {
             const factory = this.componentFactoryResolver.resolveComponentFactory(this.componentType);
+            const injector = Injector.create({
+                providers: [{ provide: INPUT_COMPONENT_OPTIONS, useValue: this.componentOptions }],
+                parent: this.injector,
+            });
 
             // create a temp instance to check the value of `isListInput`
-            const cmpRef = factory.create(this.injector);
+            const cmpRef = factory.create(injector);
             const isListInputComponent = cmpRef.instance.isListInput ?? false;
             cmpRef.destroy();
 
@@ -124,6 +133,7 @@ export class DynamicFormInputComponent
             if (!this.renderAsList) {
                 this.singleComponentRef = this.renderInputComponent(
                     factory,
+                    injector,
                     this.singleViewContainer,
                     this.control,
                 );
@@ -142,6 +152,7 @@ export class DynamicFormInputComponent
                                 this.listFormArray.push(listItem.control);
                                 listItem.componentRef = this.renderInputComponent(
                                     factory,
+                                    injector,
                                     ref,
                                     listItem.control,
                                 );
@@ -244,10 +255,11 @@ export class DynamicFormInputComponent
 
     private renderInputComponent(
         factory: ComponentFactory<FormInputComponent>,
+        injector: Injector,
         viewContainerRef: ViewContainerRef,
         formControl: UntypedFormControl,
     ) {
-        const componentRef = viewContainerRef.createComponent(factory);
+        const componentRef = viewContainerRef.createComponent(factory, undefined, injector);
         const { instance } = componentRef;
         instance.config = simpleDeepClone(this.def);
         instance.formControl = formControl;

+ 7 - 0
packages/admin-ui/src/lib/react/ng-package.json

@@ -0,0 +1,7 @@
+{
+  "lib": {
+    "styleIncludePaths": [
+      "../static/styles"
+    ]
+  }
+}

+ 47 - 0
packages/admin-ui/src/lib/react/src/adapters.ts

@@ -0,0 +1,47 @@
+import { APP_INITIALIZER, Component, FactoryProvider, inject, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import {
+    ComponentRegistryService,
+    CustomField,
+    FormInputComponent,
+    INPUT_COMPONENT_OPTIONS,
+} from '@vendure/admin-ui/core';
+import { ElementType } from 'react';
+import { ReactComponentHostDirective } from './react-component-host.directive';
+import { ReactFormInputProps } from './types';
+
+@Component({
+    selector: 'vdr-react-form-input-component',
+    template: ` <div [vdrReactComponentHost]="reactComponent" [props]="props"></div> `,
+    standalone: true,
+    imports: [ReactComponentHostDirective],
+})
+class ReactFormInputComponent implements FormInputComponent, OnInit {
+    static readonly id: string = 'react-form-input-component';
+    readonly: boolean;
+    formControl: FormControl;
+    config: CustomField & Record<string, any>;
+
+    protected props: ReactFormInputProps;
+
+    protected reactComponent = inject(INPUT_COMPONENT_OPTIONS).component;
+
+    ngOnInit() {
+        this.props = {
+            formControl: this.formControl,
+            readonly: this.readonly,
+            config: this.config,
+        };
+    }
+}
+
+export function registerReactFormInputComponent(id: string, component: ElementType): FactoryProvider {
+    return {
+        provide: APP_INITIALIZER,
+        multi: true,
+        useFactory: (registry: ComponentRegistryService) => () => {
+            registry.registerInputComponent(id, ReactFormInputComponent, { component });
+        },
+        deps: [ComponentRegistryService],
+    };
+}

+ 44 - 0
packages/admin-ui/src/lib/react/src/hooks/use-form-control.ts

@@ -0,0 +1,44 @@
+import { CustomFieldType } from '@vendure/common/lib/shared-types';
+import React, { useContext, useEffect, useState } from 'react';
+import { HostedComponentContext } from '../react-component-host.directive';
+
+/**
+ * @description
+ * Provides access to the current FormControl value and a method to update the value.
+ */
+export function useFormControl() {
+    const context = useContext(HostedComponentContext);
+    if (!context) {
+        throw new Error('No HostedComponentContext found');
+    }
+    const { formControl, config } = context;
+    const [value, setValue] = useState(formControl.value ?? 0);
+
+    useEffect(() => {
+        const subscription = formControl.valueChanges.subscribe(v => {
+            setValue(v);
+        });
+        return () => {
+            subscription.unsubscribe();
+        };
+    }, []);
+
+    function setFormValue(newValue: any) {
+        formControl.setValue(coerceFormValue(newValue, config.type as CustomFieldType));
+        formControl.markAsDirty();
+    }
+
+    return { value, setFormValue };
+}
+
+function coerceFormValue(value: any, type: CustomFieldType) {
+    switch (type) {
+        case 'int':
+        case 'float':
+            return Number(value);
+        case 'boolean':
+            return Boolean(value);
+        default:
+            return value;
+    }
+}

+ 11 - 0
packages/admin-ui/src/lib/react/src/hooks/use-injector.ts

@@ -0,0 +1,11 @@
+import { useContext } from 'react';
+import { HostedComponentContext } from '../react-component-host.directive';
+
+export function useInjector(token: any) {
+    const context = useContext(HostedComponentContext);
+    const instance = context?.injector.get(token);
+    if (!instance) {
+        throw new Error(`Could not inject ${token.name ?? token.toString()}`);
+    }
+    return instance;
+}

+ 56 - 0
packages/admin-ui/src/lib/react/src/hooks/use-query.ts

@@ -0,0 +1,56 @@
+import { TypedDocumentNode } from '@graphql-typed-document-node/core';
+import { DataService } from '@vendure/admin-ui/core';
+import { DocumentNode } from 'graphql/index';
+import { useContext, useState, useCallback, useEffect } from 'react';
+import { Observable } from 'rxjs';
+import { HostedComponentContext } from '../react-component-host.directive';
+
+export function useQuery<T, V extends Record<string, any> = Record<string, any>>(
+    query: DocumentNode | TypedDocumentNode<T, V>,
+    variables?: V,
+) {
+    const { data, loading, error, refetch } = useDataService(
+        dataService => dataService.query(query, variables).stream$,
+    );
+    return { data, loading, error, refetch };
+}
+
+export function useMutation<T, V extends Record<string, any> = Record<string, any>>(
+    mutation: DocumentNode | TypedDocumentNode<T, V>,
+) {
+    const { data, loading, error, refetch } = useDataService(dataService => dataService.mutate(mutation));
+    return { data, loading, error, refetch };
+}
+
+function useDataService<T, V extends Record<string, any> = Record<string, any>>(
+    operation: (dataService: DataService) => Observable<T>,
+) {
+    const context = useContext(HostedComponentContext);
+    const dataService = context?.injector.get(DataService);
+    if (!dataService) {
+        throw new Error('No DataService found in HostedComponentContext');
+    }
+
+    const [data, setData] = useState<T>();
+    const [error, setError] = useState<string>();
+    const [loading, setLoading] = useState(false);
+
+    const runQuery = useCallback(() => {
+        setLoading(true);
+        operation(dataService).subscribe({
+            next: (res: any) => {
+                setData(res.data);
+            },
+            error: err => {
+                setError(err.message);
+                setLoading(false);
+            },
+        });
+    }, []);
+
+    useEffect(() => {
+        runQuery();
+    }, [runQuery]);
+
+    return { data, loading, error, refetch: runQuery };
+}

+ 7 - 0
packages/admin-ui/src/lib/react/src/public_api.ts

@@ -0,0 +1,7 @@
+// This file was generated by the build-public-api.ts script
+export * from './adapters';
+export * from './hooks/use-form-control';
+export * from './hooks/use-injector';
+export * from './hooks/use-query';
+export * from './react-component-host.directive';
+export * from './types';

+ 44 - 0
packages/admin-ui/src/lib/react/src/react-component-host.directive.ts

@@ -0,0 +1,44 @@
+import { Directive, ElementRef, Injector, Input } from '@angular/core';
+import { ComponentProps, createContext, createElement, ElementType } from 'react';
+import { createRoot, Root } from 'react-dom/client';
+import { HostedReactComponentContext } from './types';
+
+export const HostedComponentContext = createContext<HostedReactComponentContext | null>(null);
+
+/**
+ * Based on https://netbasal.com/using-react-in-angular-applications-1bb907ecac91
+ */
+@Directive({
+    selector: '[vdrReactComponentHost]',
+    standalone: true,
+})
+export class ReactComponentHostDirective<Comp extends ElementType> {
+    @Input('vdrReactComponentHost') reactComponent: Comp;
+    @Input() props: ComponentProps<Comp>;
+
+    private root: Root | null = null;
+
+    constructor(private host: ElementRef, private injector: Injector) {}
+
+    async ngOnChanges() {
+        const Comp = this.reactComponent;
+
+        if (!this.root) {
+            this.root = createRoot(this.host.nativeElement);
+        }
+
+        this.root.render(
+            createElement(
+                HostedComponentContext.Provider,
+                {
+                    value: { ...this.props, injector: this.injector },
+                },
+                createElement(Comp, this.props),
+            ),
+        );
+    }
+
+    ngOnDestroy() {
+        this.root?.unmount();
+    }
+}

+ 13 - 0
packages/admin-ui/src/lib/react/src/types.ts

@@ -0,0 +1,13 @@
+import { Injector } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { CustomField } from '@vendure/admin-ui/core';
+
+export interface ReactFormInputProps {
+    formControl: FormControl;
+    readonly: boolean;
+    config: CustomField & Record<string, any>;
+}
+
+export interface HostedReactComponentContext extends ReactFormInputProps {
+    injector: Injector;
+}

+ 22 - 0
packages/dev-server/test-plugins/experimental-ui/ReactNumberInput.tsx

@@ -0,0 +1,22 @@
+import { NotificationService } from '@vendure/admin-ui/core';
+import { useFormControl, ReactFormInputProps, useInjector } from '@vendure/admin-ui/react';
+import React from 'react';
+
+export function ReactNumberInput({ readonly }: ReactFormInputProps) {
+    const { value, setFormValue } = useFormControl();
+    const notificationService = useInjector(NotificationService);
+    const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+        const val = +e.target.value;
+        if (val === 0) {
+            notificationService.error('Cannot be zero');
+        } else {
+            setFormValue(val);
+        }
+    };
+    return (
+        <div>
+            This is a React component!
+            <input readOnly={readonly} type="number" onChange={handleChange} value={value} />
+        </div>
+    );
+}

+ 33 - 0
packages/dev-server/test-plugins/experimental-ui/ui-extensions.ts

@@ -0,0 +1,33 @@
+import { addNavMenuItem, addNavMenuSection } from '@vendure/admin-ui/core';
+import { registerReactFormInputComponent } from '@vendure/admin-ui/react';
+
+import { ReactNumberInput } from './ReactNumberInput';
+
+export default [
+    addNavMenuSection(
+        {
+            id: 'greeter',
+            label: 'My Extensions',
+            items: [
+                {
+                    id: 'greeter',
+                    label: 'Greeter',
+                    routerLink: ['/extensions/greet'],
+                    icon: 'cursor-hand-open',
+                },
+            ],
+        },
+        // Add this section before the "settings" section
+        'settings',
+    ),
+    addNavMenuItem(
+        {
+            id: 'reviews',
+            label: 'Product Reviews',
+            routerLink: ['/extensions/reviews'],
+            icon: 'star',
+        },
+        'marketing',
+    ),
+    registerReactFormInputComponent('react-number-input', ReactNumberInput),
+];

+ 2 - 1
packages/dev-server/tsconfig.json

@@ -2,7 +2,8 @@
   "extends": "../../tsconfig",
   "compilerOptions": {
     "module": "commonjs",
-    "sourceMap": true
+    "sourceMap": true,
+    "jsx": "react",
   },
   "exclude": [
     "node_modules"

+ 47 - 1
yarn.lock

@@ -5388,6 +5388,11 @@
     "@types/node" "*"
     kleur "^3.0.3"
 
+"@types/prop-types@*":
+  version "15.7.5"
+  resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf"
+  integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==
+
 "@types/qs@*":
   version "6.9.7"
   resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
@@ -5398,6 +5403,15 @@
   resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
   integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
 
+"@types/react@^18.2.21":
+  version "18.2.21"
+  resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.21.tgz#774c37fd01b522d0b91aed04811b58e4e0514ed9"
+  integrity sha512-neFKG/sBAwGxHgXiIxnbm3/AAVQ/cMRS93hvBpg8xYRbeQSPVABp9U2bRnPf0iI4+Ucdv3plSxKK+3CW2ENJxA==
+  dependencies:
+    "@types/prop-types" "*"
+    "@types/scheduler" "*"
+    csstype "^3.0.2"
+
 "@types/resize-observer-browser@^0.1.3":
   version "0.1.7"
   resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.7.tgz#294aaadf24ac6580b8fbd1fe3ab7b59fe85f9ef3"
@@ -5413,6 +5427,11 @@
   resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d"
   integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==
 
+"@types/scheduler@*":
+  version "0.16.3"
+  resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5"
+  integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==
+
 "@types/semver@^6.2.2":
   version "6.2.3"
   resolved "https://registry.yarnpkg.com/@types/semver/-/semver-6.2.3.tgz#5798ecf1bec94eaa64db39ee52808ec0693315aa"
@@ -8218,6 +8237,11 @@ cssstyle@^2.3.0:
   dependencies:
     cssom "~0.3.6"
 
+csstype@^3.0.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b"
+  integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
+
 csv-parse@*:
   version "5.4.0"
   resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.4.0.tgz#6793210a4a49a9a74b3fde3f9d00f3f52044fd89"
@@ -12890,7 +12914,7 @@ long@^5.0.0:
   resolved "https://registry.yarnpkg.com/long/-/long-5.2.3.tgz#a3ba97f3877cf1d778eccbcb048525ebb77499e1"
   integrity sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==
 
-loose-envify@^1.0.0, loose-envify@^1.4.0:
+loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
   integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
@@ -15929,6 +15953,14 @@ rc@^1.2.7:
     minimist "^1.2.0"
     strip-json-comments "~2.0.1"
 
+react-dom@^18.2.0:
+  version "18.2.0"
+  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"
+  integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==
+  dependencies:
+    loose-envify "^1.1.0"
+    scheduler "^0.23.0"
+
 react-is@^16.13.1, react-is@^16.7.0:
   version "16.13.1"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@@ -15939,6 +15971,13 @@ react-is@^18.0.0:
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
   integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==
 
+react@^18.2.0:
+  version "18.2.0"
+  resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"
+  integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==
+  dependencies:
+    loose-envify "^1.1.0"
+
 read-cmd-shim@4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-4.0.0.tgz#640a08b473a49043e394ae0c7a34dd822c73b9bb"
@@ -16568,6 +16607,13 @@ saxes@^5.0.1:
   dependencies:
     xmlchars "^2.2.0"
 
+scheduler@^0.23.0:
+  version "0.23.0"
+  resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe"
+  integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==
+  dependencies:
+    loose-envify "^1.1.0"
+
 schema-utils@^3.1.1, schema-utils@^3.2.0:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe"