From 55d9ffcdb3fefe5ed78866ed7e0d9db4c6551b0a Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Wed, 6 Sep 2023 09:59:51 +0200 Subject: [PATCH] feat(admin-ui): Support for React-based custom detail components --- .../custom-detail-component-types.ts | 3 +- .../custom-detail-component-host.component.ts | 10 ++- .../react-custom-detail.component.ts | 39 +++++++++++ .../src/hooks/use-detail-component-data.ts | 56 +++++++++++++++ .../admin-ui/src/lib/react/src/providers.ts | 70 ++++++++++++++++++- .../admin-ui/src/lib/react/src/public_api.ts | 2 + .../src/register-react-route-component.ts | 6 ++ 7 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 packages/admin-ui/src/lib/react/src/components/react-custom-detail.component.ts create mode 100644 packages/admin-ui/src/lib/react/src/hooks/use-detail-component-data.ts diff --git a/packages/admin-ui/src/lib/core/src/providers/custom-detail-component/custom-detail-component-types.ts b/packages/admin-ui/src/lib/core/src/providers/custom-detail-component/custom-detail-component-types.ts index d0dbbb9681..ad2a1257d8 100644 --- a/packages/admin-ui/src/lib/core/src/providers/custom-detail-component/custom-detail-component-types.ts +++ b/packages/admin-ui/src/lib/core/src/providers/custom-detail-component/custom-detail-component-types.ts @@ -1,4 +1,4 @@ -import { Type } from '@angular/core'; +import { Provider, Type } from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; import { Observable } from 'rxjs'; @@ -25,4 +25,5 @@ export interface CustomDetailComponent { export interface CustomDetailComponentConfig { locationId: CustomDetailComponentLocationId; component: Type; + providers?: Provider[]; } diff --git a/packages/admin-ui/src/lib/core/src/shared/components/custom-detail-component-host/custom-detail-component-host.component.ts b/packages/admin-ui/src/lib/core/src/shared/components/custom-detail-component-host/custom-detail-component-host.component.ts index e6b63aa2e9..7a2e3d02bb 100644 --- a/packages/admin-ui/src/lib/core/src/shared/components/custom-detail-component-host/custom-detail-component-host.component.ts +++ b/packages/admin-ui/src/lib/core/src/shared/components/custom-detail-component-host/custom-detail-component-host.component.ts @@ -31,8 +31,8 @@ export class CustomDetailComponentHostComponent implements OnInit, OnDestroy { constructor( private viewContainerRef: ViewContainerRef, - private componentFactoryResolver: ComponentFactoryResolver, private customDetailComponentService: CustomDetailComponentService, + private injector: Injector, ) {} ngOnInit(): void { @@ -41,8 +41,12 @@ export class CustomDetailComponentHostComponent implements OnInit, OnDestroy { ); for (const config of customComponents) { - const factory = this.componentFactoryResolver.resolveComponentFactory(config.component); - const componentRef = this.viewContainerRef.createComponent(factory); + const componentRef = this.viewContainerRef.createComponent(config.component, { + injector: Injector.create({ + parent: this.injector, + providers: config.providers ?? [], + }), + }); componentRef.instance.entity$ = this.entity$; componentRef.instance.detailForm = this.detailForm; this.componentRefs.push(componentRef); diff --git a/packages/admin-ui/src/lib/react/src/components/react-custom-detail.component.ts b/packages/admin-ui/src/lib/react/src/components/react-custom-detail.component.ts new file mode 100644 index 0000000000..b8c6a597e2 --- /dev/null +++ b/packages/admin-ui/src/lib/react/src/components/react-custom-detail.component.ts @@ -0,0 +1,39 @@ +import { Component, inject, InjectionToken, OnInit, ViewEncapsulation } from '@angular/core'; +import { FormGroup, UntypedFormGroup } from '@angular/forms'; +import { CustomDetailComponent } from '@vendure/admin-ui/core'; +import { ElementType } from 'react'; +import { Observable } from 'rxjs'; +import { ReactComponentHostDirective } from '../react-component-host.directive'; + +export const REACT_CUSTOM_DETAIL_COMPONENT_OPTIONS = new InjectionToken<{ + component: ElementType; + props?: Record; +}>('REACT_CUSTOM_DETAIL_COMPONENT_OPTIONS'); + +export interface ReactCustomDetailComponentContext { + detailForm: FormGroup; + entity$: Observable; +} + +@Component({ + selector: 'vdr-react-custom-detail-component', + template: `
`, + styleUrls: ['./react-global-styles.scss'], + encapsulation: ViewEncapsulation.None, + standalone: true, + imports: [ReactComponentHostDirective], +}) +export class ReactCustomDetailComponent implements CustomDetailComponent, OnInit { + detailForm: UntypedFormGroup; + entity$: Observable; + protected props = inject(REACT_CUSTOM_DETAIL_COMPONENT_OPTIONS).props ?? {}; + protected reactComponent = inject(REACT_CUSTOM_DETAIL_COMPONENT_OPTIONS).component; + protected context: ReactCustomDetailComponentContext; + + ngOnInit() { + this.context = { + detailForm: this.detailForm, + entity$: this.entity$, + }; + } +} diff --git a/packages/admin-ui/src/lib/react/src/hooks/use-detail-component-data.ts b/packages/admin-ui/src/lib/react/src/hooks/use-detail-component-data.ts new file mode 100644 index 0000000000..c7bd2b2a68 --- /dev/null +++ b/packages/admin-ui/src/lib/react/src/hooks/use-detail-component-data.ts @@ -0,0 +1,56 @@ +import { useContext, useEffect, useState } from 'react'; +import { ReactCustomDetailComponentContext } from '../components/react-custom-detail.component'; +import { HostedComponentContext } from '../react-component-host.directive'; +import { HostedReactComponentContext } from '../types'; + +/** + * @description + * Provides the data available to React-based CustomDetailComponents. + * + * @example + * ```ts + * import { Card, useDetailComponentData } from '@vendure/admin-ui/react'; + * import React from 'react'; + * + * export function CustomDetailComponent(props: any) { + * const { entity, detailForm } = useDetailComponentData(); + * const updateName = () => { + * detailForm.get('name')?.setValue('New name'); + * detailForm.markAsDirty(); + * }; + * return ( + * + * + *
{JSON.stringify(entity, null, 2)}
+ *
+ * ); + * } + * ``` + * + * @docsCategory react-hooks + */ +export function useDetailComponentData() { + const context = useContext( + HostedComponentContext, + ) as HostedReactComponentContext; + + if (!context.detailForm || !context.entity$) { + throw new Error(`The useDetailComponentData hook can only be used within a CustomDetailComponent`); + } + + const [entity, setEntity] = useState(null); + + useEffect(() => { + const subscription = context.entity$.subscribe(value => { + setEntity(value); + }); + return () => subscription.unsubscribe(); + }, []); + + return { + entity, + detailForm: context.detailForm, + }; +} diff --git a/packages/admin-ui/src/lib/react/src/providers.ts b/packages/admin-ui/src/lib/react/src/providers.ts index 29e79ab96e..b01cb83a01 100644 --- a/packages/admin-ui/src/lib/react/src/providers.ts +++ b/packages/admin-ui/src/lib/react/src/providers.ts @@ -1,8 +1,22 @@ import { APP_INITIALIZER, FactoryProvider } from '@angular/core'; -import { ComponentRegistryService } from '@vendure/admin-ui/core'; +import { + ComponentRegistryService, + CustomDetailComponentLocationId, + CustomDetailComponentService, +} from '@vendure/admin-ui/core'; import { ElementType } from 'react'; +import { + REACT_CUSTOM_DETAIL_COMPONENT_OPTIONS, + ReactCustomDetailComponent, +} from './components/react-custom-detail.component'; import { ReactFormInputComponent } from './components/react-form-input.component'; +/** + * @description + * Registers a React component to be used as a {@link FormInputComponent}. + * + * @docsCategory react-extensions + */ export function registerReactFormInputComponent(id: string, component: ElementType): FactoryProvider { return { provide: APP_INITIALIZER, @@ -13,3 +27,57 @@ export function registerReactFormInputComponent(id: string, component: ElementTy deps: [ComponentRegistryService], }; } + +/** + * @description + * Configures a React-based component to be placed in a detail page in the given location. + * + * @docsCategory react-extensions + */ +export interface ReactCustomDetailComponentConfig { + /** + * @description + * The id of the detail page location in which to place the component. + */ + locationId: CustomDetailComponentLocationId; + /** + * @description + * The React component to render. + */ + component: ElementType; + /** + * @description + * Optional props to pass to the React component. + */ + props?: Record; +} + +/** + * @description + * Registers a React component to be rendered in a detail page in the given location. + * Components used as custom detail components can make use of the {@link useDetailComponentData} hook. + * + * @docsCategory react-extensions + */ +export function registerReactCustomDetailComponent(config: ReactCustomDetailComponentConfig) { + return { + provide: APP_INITIALIZER, + multi: true, + useFactory: (customDetailComponentService: CustomDetailComponentService) => () => { + customDetailComponentService.registerCustomDetailComponent({ + component: ReactCustomDetailComponent, + locationId: config.locationId, + providers: [ + { + provide: REACT_CUSTOM_DETAIL_COMPONENT_OPTIONS, + useValue: { + component: config.component, + props: config.props, + }, + }, + ], + }); + }, + deps: [CustomDetailComponentService], + }; +} diff --git a/packages/admin-ui/src/lib/react/src/public_api.ts b/packages/admin-ui/src/lib/react/src/public_api.ts index 1ddbc3ec77..48245f4a6e 100644 --- a/packages/admin-ui/src/lib/react/src/public_api.ts +++ b/packages/admin-ui/src/lib/react/src/public_api.ts @@ -1,6 +1,8 @@ // This file was generated by the build-public-api.ts script +export * from './components/react-custom-detail.component'; export * from './components/react-form-input.component'; export * from './components/react-route.component'; +export * from './hooks/use-detail-component-data'; export * from './hooks/use-form-control'; export * from './hooks/use-injector'; export * from './hooks/use-page-metadata'; diff --git a/packages/admin-ui/src/lib/react/src/register-react-route-component.ts b/packages/admin-ui/src/lib/react/src/register-react-route-component.ts index ff137d047d..1ac59878a4 100644 --- a/packages/admin-ui/src/lib/react/src/register-react-route-component.ts +++ b/packages/admin-ui/src/lib/react/src/register-react-route-component.ts @@ -15,6 +15,12 @@ type RegisterReactRouteComponentOptions< props?: Record; }; +/** + * @description + * Registers a React component to be used as a route component. + * + * @docsCategory react-extensions + */ export function registerReactRouteComponent< Entity extends { id: string; updatedAt?: string }, T extends DocumentNode | TypedDocumentNode,