Browse Source

refactor(admin-ui): Export Link component, add docs, update public api

Michael Bromley 2 years ago
parent
commit
3e67837b38

+ 6 - 2
packages/admin-ui/scripts/build-public-api.js

@@ -7,7 +7,7 @@ const path = require('path');
 
 console.log('Generating public apis...');
 const SOURCES_DIR = path.join(__dirname, '/../src/lib');
-const APP_SOURCE_FILE_PATTERN = /\.ts$/;
+const APP_SOURCE_FILE_PATTERN = /\.tsx?$/;
 const EXCLUDED_PATTERNS = [/(public_api|spec|mock)\.ts$/];
 
 const MODULES = [
@@ -31,7 +31,11 @@ for (const moduleDir of MODULES) {
         const excluded = EXCLUDED_PATTERNS.reduce((result, re) => result || re.test(filename), false);
         if (!excluded) {
             const relativeFilename =
-                '.' + filename.replace(modulePath, '').replace(/\\/g, '/').replace(/\.ts$/, '');
+                '.' +
+                filename
+                    .replace(modulePath, '')
+                    .replace(/\\/g, '/')
+                    .replace(/\.tsx?$/, '');
             files.push(relativeFilename);
         }
     });

+ 2 - 2
packages/admin-ui/src/lib/core/src/shared/components/page-title/page-title.component.ts

@@ -40,8 +40,8 @@ export class PageTitleComponent implements OnInit, OnChanges {
     }
 
     ngOnChanges(changes: SimpleChanges) {
-        if (changes.value) {
-            this.titleChange$.next(changes.value.currentValue);
+        if (changes.title) {
+            this.titleChange$.next(changes.title.currentValue);
         }
     }
 }

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

@@ -2,11 +2,11 @@ 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';
+import { ReactFormInputOptions } from '../types';
 
 @Component({
     selector: 'vdr-react-form-input-component',
-    template: ` <div [vdrReactComponentHost]="reactComponent" [props]="props"></div> `,
+    template: ` <div [vdrReactComponentHost]="reactComponent" [context]="context" [props]="context"></div> `,
     standalone: true,
     imports: [ReactComponentHostDirective],
 })
@@ -16,12 +16,12 @@ export class ReactFormInputComponent implements FormInputComponent, OnInit {
     formControl: FormControl;
     config: CustomField & Record<string, any>;
 
-    protected props: ReactFormInputProps;
+    protected context: ReactFormInputOptions;
 
     protected reactComponent = inject(INPUT_COMPONENT_OPTIONS).component;
 
     ngOnInit() {
-        this.props = {
+        this.context = {
             formControl: this.formControl,
             readonly: this.readonly,
             config: this.config,

+ 10 - 8
packages/admin-ui/src/lib/react/src/components/react-route.component.ts

@@ -1,26 +1,28 @@
 import { Component, inject, InjectionToken } from '@angular/core';
 import { SharedModule } from '@vendure/admin-ui/core';
 import { ReactComponentHostDirective } from '../react-component-host.directive';
+import { ReactRouteComponentOptions } from '../types';
 
-export const ROUTE_COMPONENT_OPTIONS = new InjectionToken<{
-    component: any;
-    title?: string;
-    props?: Record<string, any>;
-}>('ROUTE_COMPONENT_OPTIONS');
+export const ROUTE_COMPONENT_OPTIONS = new InjectionToken<ReactRouteComponentOptions>(
+    '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-title *ngIf="title$ | async as title" [title]="title"></vdr-page-title>
         </vdr-page-header>
-        <vdr-page-body><div [vdrReactComponentHost]="reactComponent" [props]="props"></div></vdr-page-body>
+        <vdr-page-body
+            ><div [vdrReactComponentHost]="reactComponent" [props]="props" [context]="context"></div
+        ></vdr-page-body>
     `,
     standalone: true,
     imports: [ReactComponentHostDirective, SharedModule],
 })
 export class ReactRouteComponent {
-    protected title = inject(ROUTE_COMPONENT_OPTIONS).title;
+    protected title$ = inject(ROUTE_COMPONENT_OPTIONS).title$;
     protected props = inject(ROUTE_COMPONENT_OPTIONS).props;
+    protected context = inject(ROUTE_COMPONENT_OPTIONS);
     protected reactComponent = inject(ROUTE_COMPONENT_OPTIONS).component;
 }

+ 23 - 2
packages/admin-ui/src/lib/react/src/hooks/use-form-control.ts

@@ -1,11 +1,32 @@
 import { CustomFieldType } from '@vendure/common/lib/shared-types';
 import { useContext, useEffect, useState } from 'react';
 import { HostedComponentContext } from '../react-component-host.directive';
-import { HostedReactComponentContext, ReactFormInputProps } from '../types';
+import { HostedReactComponentContext, ReactFormInputOptions } from '../types';
 
 /**
  * @description
  * Provides access to the current FormControl value and a method to update the value.
+ *
+ * @example
+ * ```ts
+ * import { useFormControl, ReactFormInputProps } from '@vendure/admin-ui/react';
+ * import React from 'react';
+ *
+ * export function ReactNumberInput({ readonly }: ReactFormInputProps) {
+ *     const { value, setFormValue } = useFormControl();
+ *
+ *     const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ *         setFormValue(val);
+ *     };
+ *     return (
+ *         <div>
+ *             <input readOnly={readonly} type="number" onChange={handleChange} value={value} />
+ *         </div>
+ *     );
+ * }
+ * ```
+ *
+ * @docsCategory react-hooks
  */
 export function useFormControl() {
     const context = useContext(HostedComponentContext);
@@ -37,7 +58,7 @@ export function useFormControl() {
 
 function isFormInputContext(
     context: HostedReactComponentContext,
-): context is HostedReactComponentContext<ReactFormInputProps> {
+): context is HostedReactComponentContext<ReactFormInputOptions> {
     return context.config && context.formControl;
 }
 

+ 22 - 0
packages/admin-ui/src/lib/react/src/hooks/use-injector.ts

@@ -2,6 +2,28 @@ import { ProviderToken } from '@angular/core';
 import { useContext } from 'react';
 import { HostedComponentContext } from '../react-component-host.directive';
 
+/**
+ * @description
+ * Exposes the Angular injector which allows the injection of services into React components.
+ *
+ * @example
+ * ```ts
+ * import { useInjector } from '@vendure/admin-ui/react';
+ * import { NotificationService } from '@vendure/admin-ui/core';
+ *
+ * export const MyComponent = () => {
+ *     const notificationService = useInjector(NotificationService);
+ *
+ *     const handleClick = () => {
+ *         notificationService.success('Hello world!');
+ *     };
+ *     // ...
+ *     return <div>...</div>;
+ * }
+ * ```
+ *
+ * @docsCategory react-hooks
+ */
 export function useInjector<T = any>(token: ProviderToken<T>): T {
     const context = useContext(HostedComponentContext);
     const instance = context?.injector.get(token);

+ 46 - 0
packages/admin-ui/src/lib/react/src/hooks/use-page-metadata.ts

@@ -0,0 +1,46 @@
+import { BreadcrumbValue } from '@vendure/admin-ui/core';
+import { useContext } from 'react';
+import { HostedComponentContext } from '../react-component-host.directive';
+import { HostedReactComponentContext, ReactRouteComponentOptions } from '../types';
+
+/**
+ * @description
+ * Provides functions for setting the current page title and breadcrumb.
+ *
+ * @example
+ * ```ts
+ * import { usePageMetadata } from '@vendure/admin-ui/react';
+ * import { useEffect } from 'react';
+ *
+ * export const MyComponent = () => {
+ *     const { setTitle, setBreadcrumb } = usePageMetadata();
+ *     useEffect(() => {
+ *         setTitle('My Page');
+ *         setBreadcrumb([
+ *             { link: ['./parent'], label: 'Parent Page' },
+ *             { link: ['./'], label: 'This Page' },
+ *         ]);
+ *     }, []);
+ *     // ...
+ *     return <div>...</div>;
+ * }
+ * ```
+ *
+ * @docsCategory react-hooks
+ */
+export function usePageMetadata() {
+    const context = useContext(
+        HostedComponentContext,
+    ) as HostedReactComponentContext<ReactRouteComponentOptions>;
+    const setBreadcrumb = (newValue: BreadcrumbValue) => {
+        context.breadcrumb$.next(newValue);
+    };
+    const setTitle = (newTitle: string) => {
+        context.title$.next(newTitle);
+    };
+
+    return {
+        setBreadcrumb,
+        setTitle,
+    };
+}

+ 81 - 2
packages/admin-ui/src/lib/react/src/hooks/use-query.ts

@@ -1,11 +1,45 @@
 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 { firstValueFrom, lastValueFrom, Observable } from 'rxjs';
+import { useCallback, useContext, useEffect, useState } from 'react';
+import { firstValueFrom, Observable } from 'rxjs';
 import { tap } from 'rxjs/operators';
 import { HostedComponentContext } from '../react-component-host.directive';
 
+/**
+ * @description
+ * A React hook which provides access to the results of a GraphQL query.
+ *
+ * @example
+ * ```ts
+ * import { useQuery } from '@vendure/admin-ui/react';
+ * import { gql } from 'graphql-tag';
+ *
+ * const GET_PRODUCT = gql`
+ *    query GetProduct($id: ID!) {
+ *      product(id: $id) {
+ *        id
+ *        name
+ *        description
+ *      }
+ *    }`;
+ *
+ * export const MyComponent = () => {
+ *     const { data, loading, error } = useQuery(GET_PRODUCT, { id: '1' });
+ *
+ *     if (loading) return <div>Loading...</div>;
+ *     if (error) return <div>Error! { error }</div>;
+ *     return (
+ *         <div>
+ *             <h1>{data.product.name}</h1>
+ *             <p>{data.product.description}</p>
+ *         </div>
+ *     );
+ * };
+ * ```
+ *
+ * @docsCategory react-hooks
+ */
 export function useQuery<T, V extends Record<string, any> = Record<string, any>>(
     query: DocumentNode | TypedDocumentNode<T, V>,
     variables?: V,
@@ -22,6 +56,51 @@ export function useQuery<T, V extends Record<string, any> = Record<string, any>>
     return { data, loading, error, refetch } as const;
 }
 
+/**
+ * @description
+ * A React hook which allows you to execute a GraphQL mutation.
+ *
+ * @example
+ * ```ts
+ * import { useMutation } from '@vendure/admin-ui/react';
+ * import { gql } from 'graphql-tag';
+ *
+ * const UPDATE_PRODUCT = gql`
+ *   mutation UpdateProduct($input: UpdateProductInput!) {
+ *     updateProduct(input: $input) {
+ *     id
+ *     name
+ *   }
+ * }`;
+ *
+ * export const MyComponent = () => {
+ *     const [updateProduct, { data, loading, error }] = useMutation(UPDATE_PRODUCT);
+ *
+ *     const handleClick = () => {
+ *         updateProduct({
+ *             input: {
+ *                 id: '1',
+ *                 name: 'New name',
+ *             },
+ *         }).then(result => {
+ *             // do something with the result
+ *         });
+ *     };
+ *
+ *     if (loading) return <div>Loading...</div>;
+ *     if (error) return <div>Error! { error }</div>;
+ *
+ *     return (
+ *     <div>
+ *         <button onClick={handleClick}>Update product</button>
+ *         {data && <div>Product updated!</div>}
+ *     </div>
+ *     );
+ * };
+ * ```
+ *
+ * @docsCategory react-hooks
+ */
 export function useMutation<T, V extends Record<string, any> = Record<string, any>>(
     mutation: DocumentNode | TypedDocumentNode<T, V>,
 ) {

+ 10 - 5
packages/admin-ui/src/lib/react/src/providers.ts

@@ -1,9 +1,11 @@
 import { APP_INITIALIZER, FactoryProvider } from '@angular/core';
 import { Route } from '@angular/router';
-import { ComponentRegistryService } from '@vendure/admin-ui/core';
+import { BreadcrumbValue, ComponentRegistryService } from '@vendure/admin-ui/core';
 import { ElementType } from 'react';
+import { BehaviorSubject } from 'rxjs';
 import { ReactFormInputComponent } from './components/react-form-input.component';
 import { ReactRouteComponent, ROUTE_COMPONENT_OPTIONS } from './components/react-route.component';
+import { ReactRouteComponentOptions } from './types';
 
 export function registerReactFormInputComponent(id: string, component: ElementType): FactoryProvider {
     return {
@@ -19,11 +21,13 @@ export function registerReactFormInputComponent(id: string, component: ElementTy
 export function registerReactRouteComponent(options: {
     component: ElementType;
     title?: string;
-    breadcrumb?: string;
+    breadcrumb?: BreadcrumbValue;
     path?: string;
     props?: Record<string, any>;
     routeConfig?: Route;
 }): Route {
+    const breadcrumbSubject$ = new BehaviorSubject<BreadcrumbValue>(options.breadcrumb ?? '');
+    const titleSubject$ = new BehaviorSubject<string | undefined>(options.title);
     return {
         path: options.path ?? '',
         providers: [
@@ -31,14 +35,15 @@ export function registerReactRouteComponent(options: {
                 provide: ROUTE_COMPONENT_OPTIONS,
                 useValue: {
                     component: options.component,
-                    title: options.title,
+                    title$: titleSubject$,
+                    breadcrumb$: breadcrumbSubject$,
                     props: options.props,
-                },
+                } satisfies ReactRouteComponentOptions,
             },
             ...(options.routeConfig?.providers ?? []),
         ],
         data: {
-            breadcrumb: options.breadcrumb,
+            breadcrumb: breadcrumbSubject$,
             ...(options.routeConfig?.data ?? {}),
         },
         ...(options.routeConfig ?? {}),

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

@@ -3,7 +3,9 @@ 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-page-metadata';
 export * from './hooks/use-query';
 export * from './providers';
 export * from './react-component-host.directive';
+export * from './react-components/Link';
 export * from './types';

+ 2 - 1
packages/admin-ui/src/lib/react/src/react-component-host.directive.ts

@@ -15,6 +15,7 @@ export const HostedComponentContext = createContext<HostedReactComponentContext
 export class ReactComponentHostDirective<Comp extends ElementType> {
     @Input('vdrReactComponentHost') reactComponent: Comp;
     @Input() props: ComponentProps<Comp>;
+    @Input() context: Record<string, any> = {};
 
     private root: Root | null = null;
 
@@ -31,7 +32,7 @@ export class ReactComponentHostDirective<Comp extends ElementType> {
             createElement(
                 HostedComponentContext.Provider,
                 {
-                    value: { ...this.props, injector: this.injector },
+                    value: { ...this.props, ...this.context, injector: this.injector },
                 },
                 createElement(Comp, this.props),
             ),

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

@@ -2,6 +2,23 @@ import { Router } from '@angular/router';
 import React, { PropsWithChildren } from 'react';
 import { useInjector } from '../hooks/use-injector';
 
+/**
+ * @description
+ * A React component which renders an anchor tag and navigates to the specified route when clicked.
+ * This is useful when you want to use a React component in a Vendure UI plugin which navigates to
+ * a route in the admin-ui.
+ *
+ * @example
+ * ```ts
+ * import { Link } from '@vendure/admin-ui/react';
+ *
+ * export const MyReactComponent = () => {
+ *     return <Link href="/extensions/my-extension">Go to my extension</Link>;
+ * }
+ * ```
+ *
+ * @docsCategory react-components
+ */
 export function Link(props: PropsWithChildren<{ href: string; [props: string]: any }>) {
     const router = useInjector(Router);
     const { href, ...rest } = props;

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

@@ -1,13 +1,23 @@
 import { Injector } from '@angular/core';
 import { FormControl } from '@angular/forms';
-import { CustomField } from '@vendure/admin-ui/core';
+import { BreadcrumbValue, CustomField } from '@vendure/admin-ui/core';
+import { Subject } from 'rxjs';
 
-export interface ReactFormInputProps {
+export interface ReactFormInputOptions {
     formControl: FormControl;
     readonly: boolean;
     config: CustomField & Record<string, any>;
 }
 
+export interface ReactFormInputProps extends ReactFormInputOptions {}
+
+export interface ReactRouteComponentOptions {
+    component: any;
+    title$: Subject<string | undefined>;
+    breadcrumb$: Subject<BreadcrumbValue>;
+    props?: Record<string, any>;
+}
+
 export type HostedReactComponentContext<T extends Record<string, any> = Record<string, any>> = {
     injector: Injector;
 } & T;

+ 1 - 0
packages/admin-ui/tsconfig.json

@@ -16,6 +16,7 @@
     "skipLibCheck": true,
     "esModuleInterop": true,
     "allowSyntheticDefaultImports": true,
+    "jsx": "react",
     "typeRoots": [
       "node_modules/@types",
       "./typings"

+ 27 - 2
packages/dev-server/test-plugins/experimental-ui/components/Greeter.tsx

@@ -1,19 +1,44 @@
 import { NotificationService } from '@vendure/admin-ui/core';
-import { useInjector } from '@vendure/admin-ui/react';
-import React from 'react';
+import { useInjector, usePageMetadata } from '@vendure/admin-ui/react';
+import React, { useState, useEffect } from 'react';
 
 export function Greeter(props: { name: string }) {
     const notificationService = useInjector(NotificationService);
+    const { setTitle, setBreadcrumb } = usePageMetadata();
+    const [titleValue, setTitleValue] = useState('');
+    const [breadcrumbValue, setBreadcrumbValue] = useState('Greeter');
+
+    useEffect(() => {
+        setTitle('My Page');
+        setBreadcrumb([
+            { link: ['./parent'], label: 'Parent Page' },
+            { link: ['./'], label: 'This Page' },
+        ]);
+    }, []);
 
     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 className="card">
+                <input value={titleValue} onInput={e => setTitleValue((e.target as any).value)} />
+                <button className="button secondary" onClick={() => setTitle(titleValue)}>
+                    Set title
+                </button>
+            </div>
+            <div className="card">
+                <input value={breadcrumbValue} onInput={e => setBreadcrumbValue((e.target as any).value)} />
+                <button className="button secondary" onClick={() => setBreadcrumb(breadcrumbValue)}>
+                    Set breadcrumb
+                </button>
+            </div>
         </div>
     );
 }

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

@@ -1,17 +0,0 @@
-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>
-    );
-}

+ 1 - 3
packages/dev-server/test-plugins/experimental-ui/components/ProductList.tsx

@@ -1,10 +1,8 @@
 import { NotificationService } from '@vendure/admin-ui/core';
-import { useInjector, useMutation, useQuery } from '@vendure/admin-ui/react';
+import { Link, 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 }) {

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

@@ -1,8 +1,8 @@
 import { NotificationService } from '@vendure/admin-ui/core';
-import { useFormControl, ReactFormInputProps, useInjector } from '@vendure/admin-ui/react';
+import { useFormControl, ReactFormInputOptions, useInjector } from '@vendure/admin-ui/react';
 import React from 'react';
 
-export function ReactNumberInput({ readonly }: ReactFormInputProps) {
+export function ReactNumberInput({ readonly }: ReactFormInputOptions) {
     const { value, setFormValue } = useFormControl();
     const notificationService = useInjector(NotificationService);