From 83d57565e5308e4179da24de2a26d09556a30011 Mon Sep 17 00:00:00 2001 From: Michael Bromley Date: Fri, 1 Sep 2023 16:40:50 +0200 Subject: [PATCH] feat(admin-ui): Initial support for React UI extensions --- .../component-registry.service.ts | 2 +- .../admin-ui/src/lib/react/src/adapters.ts | 47 --------- .../components/react-form-input.component.ts | 30 ++++++ .../src/components/react-route.component.ts | 26 +++++ .../lib/react/src/hooks/use-form-control.ts | 12 ++- .../src/lib/react/src/hooks/use-injector.ts | 5 +- .../src/lib/react/src/hooks/use-query.ts | 54 ++++++----- .../admin-ui/src/lib/react/src/providers.ts | 47 +++++++++ .../admin-ui/src/lib/react/src/public_api.ts | 4 +- .../lib/react/src/react-components/Link.tsx | 19 ++++ packages/admin-ui/src/lib/react/src/types.ts | 4 +- .../src/lib/settings/src/settings.routes.ts | 4 - .../experimental-ui/components/Greeter.tsx | 19 ++++ .../experimental-ui/components/Link.tsx | 17 ++++ .../components/ProductList.tsx | 96 +++++++++++++++++++ .../{ => components}/ReactNumberInput.tsx | 3 +- .../test-plugins/experimental-ui/routes.ts | 22 +++++ .../experimental-ui/ui-extensions.ts | 22 ++--- 18 files changed, 339 insertions(+), 94 deletions(-) delete mode 100644 packages/admin-ui/src/lib/react/src/adapters.ts create mode 100644 packages/admin-ui/src/lib/react/src/components/react-form-input.component.ts create mode 100644 packages/admin-ui/src/lib/react/src/components/react-route.component.ts create mode 100644 packages/admin-ui/src/lib/react/src/providers.ts create mode 100644 packages/admin-ui/src/lib/react/src/react-components/Link.tsx create mode 100644 packages/dev-server/test-plugins/experimental-ui/components/Greeter.tsx create mode 100644 packages/dev-server/test-plugins/experimental-ui/components/Link.tsx create mode 100644 packages/dev-server/test-plugins/experimental-ui/components/ProductList.tsx rename packages/dev-server/test-plugins/experimental-ui/{ => components}/ReactNumberInput.tsx (94%) create mode 100644 packages/dev-server/test-plugins/experimental-ui/routes.ts diff --git a/packages/admin-ui/src/lib/core/src/providers/component-registry/component-registry.service.ts b/packages/admin-ui/src/lib/core/src/providers/component-registry/component-registry.service.ts index 5681632148..27b489ad42 100644 --- a/packages/admin-ui/src/lib/core/src/providers/component-registry/component-registry.service.ts +++ b/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'); diff --git a/packages/admin-ui/src/lib/react/src/adapters.ts b/packages/admin-ui/src/lib/react/src/adapters.ts deleted file mode 100644 index 9741f74ba7..0000000000 --- a/packages/admin-ui/src/lib/react/src/adapters.ts +++ /dev/null @@ -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: `
`, - standalone: true, - imports: [ReactComponentHostDirective], -}) -class ReactFormInputComponent implements FormInputComponent, OnInit { - static readonly id: string = 'react-form-input-component'; - readonly: boolean; - formControl: FormControl; - config: CustomField & Record; - - 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], - }; -} diff --git a/packages/admin-ui/src/lib/react/src/components/react-form-input.component.ts b/packages/admin-ui/src/lib/react/src/components/react-form-input.component.ts new file mode 100644 index 0000000000..d29457c600 --- /dev/null +++ b/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: `
`, + 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; + + protected props: ReactFormInputProps; + + protected reactComponent = inject(INPUT_COMPONENT_OPTIONS).component; + + ngOnInit() { + this.props = { + formControl: this.formControl, + readonly: this.readonly, + config: this.config, + }; + } +} diff --git a/packages/admin-ui/src/lib/react/src/components/react-route.component.ts b/packages/admin-ui/src/lib/react/src/components/react-route.component.ts new file mode 100644 index 0000000000..13b555faa4 --- /dev/null +++ b/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; +}>('ROUTE_COMPONENT_OPTIONS'); + +@Component({ + selector: 'vdr-react-route-component', + template: ` + + + +
+ `, + 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; +} diff --git a/packages/admin-ui/src/lib/react/src/hooks/use-form-control.ts b/packages/admin-ui/src/lib/react/src/hooks/use-form-control.ts index 847ef9132b..0de9de0134 100644 --- a/packages/admin-ui/src/lib/react/src/hooks/use-form-control.ts +++ b/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 { + return context.config && context.formControl; +} + function coerceFormValue(value: any, type: CustomFieldType) { switch (type) { case 'int': diff --git a/packages/admin-ui/src/lib/react/src/hooks/use-injector.ts b/packages/admin-ui/src/lib/react/src/hooks/use-injector.ts index 445909ce97..46f112e9ee 100644 --- a/packages/admin-ui/src/lib/react/src/hooks/use-injector.ts +++ b/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(token: ProviderToken): 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; } diff --git a/packages/admin-ui/src/lib/react/src/hooks/use-query.ts b/packages/admin-ui/src/lib/react/src/hooks/use-query.ts index ed9016404e..67262ab4ba 100644 --- a/packages/admin-ui/src/lib/react/src/hooks/use-query.ts +++ b/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 = Record>( query: DocumentNode | TypedDocumentNode, variables?: V, ) { - const { data, loading, error, refetch } = useDataService( - dataService => dataService.query(query, variables).stream$, + const { data, loading, error, runQuery } = useDataService( + (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 = Record>( mutation: DocumentNode | TypedDocumentNode, ) { - const { data, loading, error, refetch } = useDataService(dataService => dataService.mutate(mutation)); - return { data, loading, error, refetch }; + const { data, loading, error, runQuery } = useDataService((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 = Record>( - operation: (dataService: DataService) => Observable, + operation: (dataService: DataService, variables?: V) => Observable, ) { const context = useContext(HostedComponentContext); const dataService = context?.injector.get(DataService); @@ -35,22 +46,21 @@ function useDataService = Record>( const [error, setError] = useState(); 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 }; } diff --git a/packages/admin-ui/src/lib/react/src/providers.ts b/packages/admin-ui/src/lib/react/src/providers.ts new file mode 100644 index 0000000000..1806a787f5 --- /dev/null +++ b/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; + 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, + }; +} 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 01aba4931f..97bb3f92e8 100644 --- a/packages/admin-ui/src/lib/react/src/public_api.ts +++ b/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'; diff --git a/packages/admin-ui/src/lib/react/src/react-components/Link.tsx b/packages/admin-ui/src/lib/react/src/react-components/Link.tsx new file mode 100644 index 0000000000..fa55a066c2 --- /dev/null +++ b/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) { + e.preventDefault(); + void router.navigateByUrl(href); + } + + return ( + + {props.children} + + ); +} diff --git a/packages/admin-ui/src/lib/react/src/types.ts b/packages/admin-ui/src/lib/react/src/types.ts index fd0be55956..462b9b51ef 100644 --- a/packages/admin-ui/src/lib/react/src/types.ts +++ b/packages/admin-ui/src/lib/react/src/types.ts @@ -8,6 +8,6 @@ export interface ReactFormInputProps { config: CustomField & Record; } -export interface HostedReactComponentContext extends ReactFormInputProps { +export type HostedReactComponentContext = Record> = { injector: Injector; -} +} & T; diff --git a/packages/admin-ui/src/lib/settings/src/settings.routes.ts b/packages/admin-ui/src/lib/settings/src/settings.routes.ts index 2edcf7ce00..9f9a24d3d2 100644 --- a/packages/admin-ui/src/lib/settings/src/settings.routes.ts +++ b/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[] => [ { diff --git a/packages/dev-server/test-plugins/experimental-ui/components/Greeter.tsx b/packages/dev-server/test-plugins/experimental-ui/components/Greeter.tsx new file mode 100644 index 0000000000..c608c6d2b2 --- /dev/null +++ b/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 ( +
+

Hello {props.name}

+ +
+ ); +} diff --git a/packages/dev-server/test-plugins/experimental-ui/components/Link.tsx b/packages/dev-server/test-plugins/experimental-ui/components/Link.tsx new file mode 100644 index 0000000000..bf86dc2cdd --- /dev/null +++ b/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) { + e.preventDefault(); + void router.navigateByUrl(href); + } + return ( + + {props.children} + + ); +} diff --git a/packages/dev-server/test-plugins/experimental-ui/components/ProductList.tsx b/packages/dev-server/test-plugins/experimental-ui/components/ProductList.tsx new file mode 100644 index 0000000000..19ef27fc0f --- /dev/null +++ b/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 ( +
+

Loading...

+
+ ); + if (error) + return ( +
+

Error: {error}

+
+ ); + const products = (data as any).products; + return products.items.length ? ( +
+

+ Found {products.totalItems} products, showing {products.items.length}: +

+ + + + + + + + + + {products.items.map((p: any, i: any) => ( + + + + + + ))} + +
ToggleStateProduct
+ + + {p.enabled ? ( + Enabled + ) : ( + Disabled + )} + + + {p.name} + +
+
+ ) : ( +

Coudldn't find products.

+ ); +} diff --git a/packages/dev-server/test-plugins/experimental-ui/ReactNumberInput.tsx b/packages/dev-server/test-plugins/experimental-ui/components/ReactNumberInput.tsx similarity index 94% rename from packages/dev-server/test-plugins/experimental-ui/ReactNumberInput.tsx rename to packages/dev-server/test-plugins/experimental-ui/components/ReactNumberInput.tsx index 2a50474fe8..e5da7f5bdd 100644 --- a/packages/dev-server/test-plugins/experimental-ui/ReactNumberInput.tsx +++ b/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) => { - const val = +e.target.value; + const val = +(e.target as any).value; if (val === 0) { notificationService.error('Cannot be zero'); } else { diff --git a/packages/dev-server/test-plugins/experimental-ui/routes.ts b/packages/dev-server/test-plugins/experimental-ui/routes.ts new file mode 100644 index 0000000000..baccc2c3d1 --- /dev/null +++ b/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', + }), +]; diff --git a/packages/dev-server/test-plugins/experimental-ui/ui-extensions.ts b/packages/dev-server/test-plugins/experimental-ui/ui-extensions.ts index 5f121cd413..59bb204c6a 100644 --- a/packages/dev-server/test-plugins/experimental-ui/ui-extensions.ts +++ b/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), ];