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}:
+
+
+
+
+ Toggle |
+ State |
+ Product |
+
+
+
+ {products.items.map((p: any, i: any) => (
+
+
+
+ |
+
+ {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),
];