Skip to content

Commit

Permalink
feat(admin-ui): Initial support for React UI extensions
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Sep 1, 2023
1 parent 2425a33 commit 83d5756
Show file tree
Hide file tree
Showing 18 changed files with 339 additions and 94 deletions.
Original file line number Diff line number Diff line change
@@ -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');

Expand Down
47 changes: 0 additions & 47 deletions packages/admin-ui/src/lib/react/src/adapters.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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,
};
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 11 additions & 1 deletion packages/admin-ui/src/lib/react/src/hooks/use-form-control.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);

Expand All @@ -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':
Expand Down
5 changes: 3 additions & 2 deletions packages/admin-ui/src/lib/react/src/hooks/use-injector.ts
Original file line number Diff line number Diff line change
@@ -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;
}
54 changes: 32 additions & 22 deletions packages/admin-ui/src/lib/react/src/hooks/use-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 changes: 47 additions & 0 deletions packages/admin-ui/src/lib/react/src/providers.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
4 changes: 3 additions & 1 deletion packages/admin-ui/src/lib/react/src/public_api.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 19 additions & 0 deletions packages/admin-ui/src/lib/react/src/react-components/Link.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
4 changes: 2 additions & 2 deletions packages/admin-ui/src/lib/react/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
4 changes: 0 additions & 4 deletions packages/admin-ui/src/lib/settings/src/settings.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,13 @@ 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,
PageComponent,
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[] => [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading

0 comments on commit 83d5756

Please sign in to comment.