Procházet zdrojové kódy

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

Michael Bromley před 2 roky
rodič
revize
83d57565e5

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

@@ -1,6 +1,6 @@
 import { Injectable, InjectionToken, Type } from '@angular/core';
 
-import { FormInputComponent, InputComponentConfig } from '../../common/component-registry-types';
+import { FormInputComponent } from '../../common/component-registry-types';
 
 export const INPUT_COMPONENT_OPTIONS = new InjectionToken<{ component?: any }>('INPUT_COMPONENT_OPTIONS');
 

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

@@ -1,47 +0,0 @@
-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],
-    };
-}

+ 30 - 0
packages/admin-ui/src/lib/react/src/components/react-form-input.component.ts

@@ -0,0 +1,30 @@
+import { Component, inject, OnInit } from '@angular/core';
+import { FormControl } from '@angular/forms';
+import { CustomField, FormInputComponent, INPUT_COMPONENT_OPTIONS } from '@vendure/admin-ui/core';
+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],
+})
+export 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,
+        };
+    }
+}

+ 26 - 0
packages/admin-ui/src/lib/react/src/components/react-route.component.ts

@@ -0,0 +1,26 @@
+import { Component, inject, InjectionToken } from '@angular/core';
+import { SharedModule } from '@vendure/admin-ui/core';
+import { ReactComponentHostDirective } from '../react-component-host.directive';
+
+export const ROUTE_COMPONENT_OPTIONS = new InjectionToken<{
+    component: any;
+    title?: string;
+    props?: Record<string, any>;
+}>('ROUTE_COMPONENT_OPTIONS');
+
+@Component({
+    selector: 'vdr-react-route-component',
+    template: `
+        <vdr-page-header>
+            <vdr-page-title *ngIf="title" [title]="title"></vdr-page-title>
+        </vdr-page-header>
+        <vdr-page-body><div [vdrReactComponentHost]="reactComponent" [props]="props"></div></vdr-page-body>
+    `,
+    standalone: true,
+    imports: [ReactComponentHostDirective, SharedModule],
+})
+export class ReactRouteComponent {
+    protected title = inject(ROUTE_COMPONENT_OPTIONS).title;
+    protected props = inject(ROUTE_COMPONENT_OPTIONS).props;
+    protected reactComponent = inject(ROUTE_COMPONENT_OPTIONS).component;
+}

+ 11 - 1
packages/admin-ui/src/lib/react/src/hooks/use-form-control.ts

@@ -1,6 +1,7 @@
 import { CustomFieldType } from '@vendure/common/lib/shared-types';
-import React, { useContext, useEffect, useState } from 'react';
+import { useContext, useEffect, useState } from 'react';
 import { HostedComponentContext } from '../react-component-host.directive';
+import { HostedReactComponentContext, ReactFormInputProps } from '../types';
 
 /**
  * @description
@@ -11,6 +12,9 @@ export function useFormControl() {
     if (!context) {
         throw new Error('No HostedComponentContext found');
     }
+    if (!isFormInputContext(context)) {
+        throw new Error('useFormControl() can only be used in a form input component');
+    }
     const { formControl, config } = context;
     const [value, setValue] = useState(formControl.value ?? 0);
 
@@ -31,6 +35,12 @@ export function useFormControl() {
     return { value, setFormValue };
 }
 
+function isFormInputContext(
+    context: HostedReactComponentContext,
+): context is HostedReactComponentContext<ReactFormInputProps> {
+    return context.config && context.formControl;
+}
+
 function coerceFormValue(value: any, type: CustomFieldType) {
     switch (type) {
         case 'int':

+ 3 - 2
packages/admin-ui/src/lib/react/src/hooks/use-injector.ts

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

+ 32 - 22
packages/admin-ui/src/lib/react/src/hooks/use-query.ts

@@ -2,28 +2,39 @@ 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 { firstValueFrom, lastValueFrom, Observable } from 'rxjs';
+import { tap } from 'rxjs/operators';
 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$,
+    const { data, loading, error, runQuery } = useDataService<T, V>(
+        (dataService, vars) => dataService.query(query, vars).stream$,
     );
-    return { data, loading, error, refetch };
+    useEffect(() => {
+        const subscription = runQuery(variables).subscribe();
+        return () => subscription.unsubscribe();
+    }, [runQuery]);
+
+    const refetch = (variables?: V) => firstValueFrom(runQuery(variables));
+    return { data, loading, error, refetch } as const;
 }
 
 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 };
+    const { data, loading, error, runQuery } = useDataService<T, V>((dataService, variables) =>
+        dataService.mutate(mutation, variables),
+    );
+    const rest = { data, loading, error };
+    const execute = (variables?: V) => firstValueFrom(runQuery(variables));
+    return [execute, rest] as [typeof execute, typeof rest];
 }
 
 function useDataService<T, V extends Record<string, any> = Record<string, any>>(
-    operation: (dataService: DataService) => Observable<T>,
+    operation: (dataService: DataService, variables?: V) => Observable<T>,
 ) {
     const context = useContext(HostedComponentContext);
     const dataService = context?.injector.get(DataService);
@@ -35,22 +46,21 @@ function useDataService<T, V extends Record<string, any> = Record<string, any>>(
     const [error, setError] = useState<string>();
     const [loading, setLoading] = useState(false);
 
-    const runQuery = useCallback(() => {
+    const runQuery = useCallback((variables?: V) => {
         setLoading(true);
-        operation(dataService).subscribe({
-            next: (res: any) => {
-                setData(res.data);
-            },
-            error: err => {
-                setError(err.message);
-                setLoading(false);
-            },
-        });
+        return operation(dataService, variables).pipe(
+            tap({
+                next: res => {
+                    setData(res);
+                    setLoading(false);
+                },
+                error: err => {
+                    setError(err.message);
+                    setLoading(false);
+                },
+            }),
+        );
     }, []);
 
-    useEffect(() => {
-        runQuery();
-    }, [runQuery]);
-
-    return { data, loading, error, refetch: runQuery };
+    return { data, loading, error, runQuery };
 }

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

@@ -0,0 +1,47 @@
+import { APP_INITIALIZER, FactoryProvider } from '@angular/core';
+import { Route } from '@angular/router';
+import { ComponentRegistryService } from '@vendure/admin-ui/core';
+import { ElementType } from 'react';
+import { ReactFormInputComponent } from './components/react-form-input.component';
+import { ReactRouteComponent, ROUTE_COMPONENT_OPTIONS } from './components/react-route.component';
+
+export function registerReactFormInputComponent(id: string, component: ElementType): FactoryProvider {
+    return {
+        provide: APP_INITIALIZER,
+        multi: true,
+        useFactory: (registry: ComponentRegistryService) => () => {
+            registry.registerInputComponent(id, ReactFormInputComponent, { component });
+        },
+        deps: [ComponentRegistryService],
+    };
+}
+
+export function registerReactRouteComponent(options: {
+    component: ElementType;
+    title?: string;
+    breadcrumb?: string;
+    path?: string;
+    props?: Record<string, any>;
+    routeConfig?: Route;
+}): Route {
+    return {
+        path: options.path ?? '',
+        providers: [
+            {
+                provide: ROUTE_COMPONENT_OPTIONS,
+                useValue: {
+                    component: options.component,
+                    title: options.title,
+                    props: options.props,
+                },
+            },
+            ...(options.routeConfig?.providers ?? []),
+        ],
+        data: {
+            breadcrumb: options.breadcrumb,
+            ...(options.routeConfig?.data ?? {}),
+        },
+        ...(options.routeConfig ?? {}),
+        component: ReactRouteComponent,
+    };
+}

+ 3 - 1
packages/admin-ui/src/lib/react/src/public_api.ts

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

+ 19 - 0
packages/admin-ui/src/lib/react/src/react-components/Link.tsx

@@ -0,0 +1,19 @@
+import { Router } from '@angular/router';
+import React, { PropsWithChildren } from 'react';
+import { useInjector } from '../hooks/use-injector';
+
+export function Link(props: PropsWithChildren<{ href: string; [props: string]: any }>) {
+    const router = useInjector(Router);
+    const { href, ...rest } = props;
+
+    function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
+        e.preventDefault();
+        void router.navigateByUrl(href);
+    }
+
+    return (
+        <a href={href} onClick={onClick} {...rest}>
+            {props.children}
+        </a>
+    );
+}

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

@@ -8,6 +8,6 @@ export interface ReactFormInputProps {
     config: CustomField & Record<string, any>;
 }
 
-export interface HostedReactComponentContext extends ReactFormInputProps {
+export type HostedReactComponentContext<T extends Record<string, any> = Record<string, any>> = {
     injector: Injector;
-}
+} & T;

+ 0 - 4
packages/admin-ui/src/lib/settings/src/settings.routes.ts

@@ -2,8 +2,6 @@ import { inject } from '@angular/core';
 import { Route } from '@angular/router';
 import { marker as _ } from '@biesbjerg/ngx-translate-extract-marker';
 import {
-    CanDeactivateDetailGuard,
-    createResolveData,
     DataService,
     GetGlobalSettingsDetailDocument,
     GetProfileDetailDocument,
@@ -11,8 +9,6 @@ import {
     PageService,
 } from '@vendure/admin-ui/core';
 import { of } from 'rxjs';
-import { ProfileComponent } from './components/profile/profile.component';
-import { ProfileResolver } from './providers/routing/profile-resolver';
 
 export const createRoutes = (pageService: PageService): Route[] => [
     {

+ 19 - 0
packages/dev-server/test-plugins/experimental-ui/components/Greeter.tsx

@@ -0,0 +1,19 @@
+import { NotificationService } from '@vendure/admin-ui/core';
+import { useInjector } from '@vendure/admin-ui/react';
+import React from 'react';
+
+export function Greeter(props: { name: string }) {
+    const notificationService = useInjector(NotificationService);
+
+    function handleClick() {
+        notificationService.success('You clicked me!');
+    }
+    return (
+        <div className="page-block">
+            <h2>Hello {props.name}</h2>
+            <button className="button primary" onClick={handleClick}>
+                Click me
+            </button>
+        </div>
+    );
+}

+ 17 - 0
packages/dev-server/test-plugins/experimental-ui/components/Link.tsx

@@ -0,0 +1,17 @@
+import { Router } from '@angular/router';
+import { useInjector } from '@vendure/admin-ui/react';
+import React, { PropsWithChildren } from 'react';
+
+export function Link(props: PropsWithChildren<{ href: string; [props: string]: any }>) {
+    const router = useInjector(Router);
+    const { href, ...rest } = props;
+    function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
+        e.preventDefault();
+        void router.navigateByUrl(href);
+    }
+    return (
+        <a href={href} onClick={onClick} {...rest}>
+            {props.children}
+        </a>
+    );
+}

+ 96 - 0
packages/dev-server/test-plugins/experimental-ui/components/ProductList.tsx

@@ -0,0 +1,96 @@
+import { NotificationService } from '@vendure/admin-ui/core';
+import { useInjector, useMutation, useQuery } from '@vendure/admin-ui/react';
+import gql from 'graphql-tag';
+import React from 'react';
+
+import { Link } from './Link';
+
+const GET_PRODUCTS = gql`
+    query GetProducts($skip: Int, $take: Int) {
+        products(options: { skip: $skip, take: $take }) {
+            items {
+                id
+                name
+                enabled
+            }
+            totalItems
+        }
+    }
+`;
+
+const TOGGLE_ENABLED = gql`
+    mutation ToggleEnabled($id: ID!, $enabled: Boolean!) {
+        updateProduct(input: { id: $id, enabled: $enabled }) {
+            id
+            enabled
+        }
+    }
+`;
+
+export function ProductList() {
+    const { data, loading, error } = useQuery(GET_PRODUCTS, { skip: 0, take: 10 });
+    const [toggleEnabled] = useMutation(TOGGLE_ENABLED);
+    const notificationService = useInjector(NotificationService);
+
+    function onToggle(id: string, enabled: boolean) {
+        toggleEnabled({ id, enabled }).then(
+            () => notificationService.success('Updated Product'),
+            reason => notificationService.error(`Couldnt update product: ${reason as string}`),
+        );
+    }
+
+    if (loading || !data)
+        return (
+            <div className="page-block">
+                <h3>Loading...</h3>
+            </div>
+        );
+    if (error)
+        return (
+            <div className="page-block">
+                <h3>Error: {error}</h3>
+            </div>
+        );
+    const products = (data as any).products;
+    return products.items.length ? (
+        <div className="page-block">
+            <h3>
+                Found {products.totalItems} products, showing {products.items.length}:
+            </h3>
+            <table className="table">
+                <thead>
+                    <tr>
+                        <th>Toggle</th>
+                        <th>State</th>
+                        <th>Product</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {products.items.map((p: any, i: any) => (
+                        <tr key={i}>
+                            <td>
+                                <button className="button-ghost" onClick={() => onToggle(p.id, !p.enabled)}>
+                                    Toggle
+                                </button>
+                            </td>
+                            <td>
+                                {p.enabled ? (
+                                    <span className="label label-success">Enabled</span>
+                                ) : (
+                                    <span className="label label-danger">Disabled</span>
+                                )}
+                            </td>
+                            <td>
+                                <Link href={`catalog/inventory/${p.id}`} className="button-ghost">
+                                    {p.name}
+                                </Link>
+                            </td>
+                        </tr>
+                    ))}
+                </tbody>
+            </table>
+        </div>
+    ) : (
+        <h3>Coudldn't find products.</h3>
+    );
+}

+ 2 - 1
packages/dev-server/test-plugins/experimental-ui/ReactNumberInput.tsx → packages/dev-server/test-plugins/experimental-ui/components/ReactNumberInput.tsx

@@ -5,8 +5,9 @@ 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;
+        const val = +(e.target as any).value;
         if (val === 0) {
             notificationService.error('Cannot be zero');
         } else {

+ 22 - 0
packages/dev-server/test-plugins/experimental-ui/routes.ts

@@ -0,0 +1,22 @@
+import { registerReactRouteComponent } from '@vendure/admin-ui/react';
+
+import { Greeter } from './components/Greeter';
+import { ProductList } from './components/ProductList';
+
+export default [
+    registerReactRouteComponent({
+        component: Greeter,
+        path: 'greet',
+        title: 'Greeter Page',
+        breadcrumb: 'Greeter',
+        props: {
+            name: 'World',
+        },
+    }),
+    registerReactRouteComponent({
+        component: ProductList,
+        path: 'products',
+        title: 'Products',
+        breadcrumb: 'Products',
+    }),
+];

+ 9 - 13
packages/dev-server/test-plugins/experimental-ui/ui-extensions.ts

@@ -1,7 +1,7 @@
-import { addNavMenuItem, addNavMenuSection } from '@vendure/admin-ui/core';
+import { addNavMenuSection } from '@vendure/admin-ui/core';
 import { registerReactFormInputComponent } from '@vendure/admin-ui/react';
 
-import { ReactNumberInput } from './ReactNumberInput';
+import { ReactNumberInput } from './components/ReactNumberInput';
 
 export default [
     addNavMenuSection(
@@ -12,22 +12,18 @@ export default [
                 {
                     id: 'greeter',
                     label: 'Greeter',
-                    routerLink: ['/extensions/greet'],
+                    routerLink: ['/extensions/example/greet'],
                     icon: 'cursor-hand-open',
                 },
+                {
+                    id: 'products',
+                    label: 'Products',
+                    routerLink: ['/extensions/example/products'],
+                    icon: 'checkbox-list',
+                },
             ],
         },
-        // 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),
 ];