Skip to content

Commit

Permalink
feat(admin-ui): Add initial React support for UI extensions
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Aug 31, 2023
1 parent 8385ce8 commit 1075dd7
Show file tree
Hide file tree
Showing 16 changed files with 363 additions and 14 deletions.
3 changes: 3 additions & 0 deletions packages/admin-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/admin-ui/scripts/build-public-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const MODULES = [
'order',
'settings',
'system',
'react',
];

for (const moduleDir of MODULES) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>();
Expand All @@ -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(
Expand All @@ -101,17 +106,21 @@ export class DynamicFormInputComponent
this.getInputComponentConfig({ ...this.def, ui: undefined } as any).component,
);
if (defaultComponentType) {
this.componentType = defaultComponentType;
this.componentType = defaultComponentType.type;
}
}
}

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();

Expand All @@ -124,6 +133,7 @@ export class DynamicFormInputComponent
if (!this.renderAsList) {
this.singleComponentRef = this.renderInputComponent(
factory,
injector,
this.singleViewContainer,
this.control,
);
Expand All @@ -142,6 +152,7 @@ export class DynamicFormInputComponent
this.listFormArray.push(listItem.control);
listItem.componentRef = this.renderInputComponent(
factory,
injector,
ref,
listItem.control,
);
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions packages/admin-ui/src/lib/react/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"lib": {
"styleIncludePaths": [
"../static/styles"
]
}
}
47 changes: 47 additions & 0 deletions packages/admin-ui/src/lib/react/src/adapters.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 44 additions & 0 deletions packages/admin-ui/src/lib/react/src/hooks/use-form-control.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 11 additions & 0 deletions packages/admin-ui/src/lib/react/src/hooks/use-injector.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 56 additions & 0 deletions packages/admin-ui/src/lib/react/src/hooks/use-query.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 7 additions & 0 deletions packages/admin-ui/src/lib/react/src/public_api.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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 changes: 13 additions & 0 deletions packages/admin-ui/src/lib/react/src/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 1075dd7

Please sign in to comment.