Quellcode durchsuchen

feat(admin-ui): Support for React-based custom detail components

Michael Bromley vor 2 Jahren
Ursprung
Commit
55d9ffcdb3

+ 2 - 1
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<CustomDetailComponent>;
+    providers?: Provider[];
 }

+ 7 - 3
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);

+ 39 - 0
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<string, any>;
+}>('REACT_CUSTOM_DETAIL_COMPONENT_OPTIONS');
+
+export interface ReactCustomDetailComponentContext {
+    detailForm: FormGroup;
+    entity$: Observable<any>;
+}
+
+@Component({
+    selector: 'vdr-react-custom-detail-component',
+    template: ` <div [vdrReactComponentHost]="reactComponent" [context]="context" [props]="props"></div> `,
+    styleUrls: ['./react-global-styles.scss'],
+    encapsulation: ViewEncapsulation.None,
+    standalone: true,
+    imports: [ReactComponentHostDirective],
+})
+export class ReactCustomDetailComponent implements CustomDetailComponent, OnInit {
+    detailForm: UntypedFormGroup;
+    entity$: Observable<any>;
+    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$,
+        };
+    }
+}

+ 56 - 0
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 (
+ *         <Card title={'Custom Detail Component'}>
+ *             <button className="button" onClick={updateName}>
+ *                 Update name
+ *             </button>
+ *             <pre>{JSON.stringify(entity, null, 2)}</pre>
+ *         </Card>
+ *     );
+ * }
+ * ```
+ *
+ * @docsCategory react-hooks
+ */
+export function useDetailComponentData() {
+    const context = useContext(
+        HostedComponentContext,
+    ) as HostedReactComponentContext<ReactCustomDetailComponentContext>;
+
+    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,
+    };
+}

+ 69 - 1
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<string, any>;
+}
+
+/**
+ * @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],
+    };
+}

+ 2 - 0
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';

+ 6 - 0
packages/admin-ui/src/lib/react/src/register-react-route-component.ts

@@ -15,6 +15,12 @@ type RegisterReactRouteComponentOptions<
     props?: Record<string, any>;
 };
 
+/**
+ * @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<any, { id: string }>,