Browse Source

feat(admin-ui): Implement simplified API for UI route extensions

Michael Bromley 2 years ago
parent
commit
b9ca367277

+ 20 - 9
packages/admin-ui/src/lib/core/src/common/base-detail.component.ts

@@ -251,10 +251,26 @@ export function detailComponentWithResolver<
     getBreadcrumbs?: (entity: ResultOf<T>[R]) => BreadcrumbValue;
     variables?: T extends TypedDocumentNode<any, infer V> ? Omit<V, 'id'> : never;
 }) {
-    const resolveFn: ResolveFn<{
-        entity: Observable<ResultOf<T>[Field] | null>;
-        result?: ResultOf<T>;
-    }> = route => {
+    return {
+        resolveFn: createBaseDetailResolveFn(config),
+        breadcrumbFn: (result: any) => config.getBreadcrumbs?.(result) ?? ([] as BreadcrumbValue[]),
+        component: config.component,
+    };
+}
+
+export function createBaseDetailResolveFn<
+    T extends TypedDocumentNode<any, { id: string }>,
+    Field extends keyof ResultOf<T>,
+    R extends Field,
+>(config: {
+    query: T;
+    entityKey: R;
+    variables?: T extends TypedDocumentNode<any, infer V> ? Omit<V, 'id'> : never;
+}): ResolveFn<{
+    entity: Observable<ResultOf<T>[Field] | null>;
+    result?: ResultOf<T>;
+}> {
+    return route => {
         const router = inject(Router);
         const dataService = inject(DataService);
         const id = route.paramMap.get('id');
@@ -282,9 +298,4 @@ export function detailComponentWithResolver<
             );
         }
     };
-    return {
-        resolveFn,
-        breadcrumbFn: (result: any) => config.getBreadcrumbs?.(result) ?? ([] as BreadcrumbValue[]),
-        component: config.component,
-    };
 }

+ 57 - 0
packages/admin-ui/src/lib/core/src/extension/components/route.component.ts

@@ -0,0 +1,57 @@
+import { Component, inject, InjectionToken } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+import { Observable, combineLatest, switchMap, of } from 'rxjs';
+import { filter, map } from 'rxjs/operators';
+import { BreadcrumbValue } from '../../providers/breadcrumb/breadcrumb.service';
+import { SharedModule } from '../../shared/shared.module';
+import { PageMetadataService } from '../providers/page-metadata.service';
+import { RouteComponentOptions } from '../types';
+
+export const ROUTE_COMPONENT_OPTIONS = new InjectionToken<RouteComponentOptions>('ROUTE_COMPONENT_OPTIONS');
+
+@Component({
+    selector: 'vdr-route-component',
+    template: `
+        <vdr-page-header>
+            <vdr-page-title *ngIf="title$ | async as title" [title]="title"></vdr-page-title>
+        </vdr-page-header>
+        <vdr-page-body><ng-container *ngComponentOutlet="component" /></vdr-page-body>
+    `,
+    standalone: true,
+    imports: [SharedModule],
+    providers: [PageMetadataService],
+})
+export class RouteComponent {
+    protected component = inject(ROUTE_COMPONENT_OPTIONS).component;
+    protected title$: Observable<string | undefined>;
+    protected context = inject(ROUTE_COMPONENT_OPTIONS);
+
+    constructor(private route: ActivatedRoute) {
+        const breadcrumbLabel$ = this.route.data.pipe(
+            switchMap(data => {
+                if (data.breadcrumb instanceof Observable) {
+                    return data.breadcrumb as Observable<BreadcrumbValue>;
+                }
+                if (typeof data.breadcrumb === 'function') {
+                    return data.breadcrumb(data) as Observable<BreadcrumbValue>;
+                }
+                return of(undefined);
+            }),
+            filter(notNullOrUndefined),
+            map(breadcrumb => {
+                if (typeof breadcrumb === 'string') {
+                    return breadcrumb;
+                }
+                if (Array.isArray(breadcrumb)) {
+                    return breadcrumb[breadcrumb.length - 1].label;
+                }
+                return breadcrumb.label;
+            }),
+        );
+
+        this.title$ = combineLatest([inject(ROUTE_COMPONENT_OPTIONS).title$, breadcrumbLabel$]).pipe(
+            map(([title, breadcrumbLabel]) => title ?? breadcrumbLabel),
+        );
+    }
+}

+ 16 - 0
packages/admin-ui/src/lib/core/src/extension/providers/page-metadata.service.ts

@@ -0,0 +1,16 @@
+import { inject, Injectable } from '@angular/core';
+import { BreadcrumbValue } from '../../providers/breadcrumb/breadcrumb.service';
+import { ROUTE_COMPONENT_OPTIONS } from '../components/route.component';
+
+@Injectable()
+export class PageMetadataService {
+    private readonly routeComponentOptions = inject(ROUTE_COMPONENT_OPTIONS);
+
+    setTitle(title: string) {
+        this.routeComponentOptions.title$.next(title);
+    }
+
+    setBreadcrumbs(value: BreadcrumbValue) {
+        this.routeComponentOptions.breadcrumb$.next(value);
+    }
+}

+ 78 - 0
packages/admin-ui/src/lib/core/src/extension/register-route-component.ts

@@ -0,0 +1,78 @@
+import { Type } from '@angular/core';
+import { ResolveFn, Route } from '@angular/router';
+import { ResultOf, TypedDocumentNode } from '@graphql-typed-document-node/core';
+import { DocumentNode } from 'graphql';
+import { BehaviorSubject, Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+import { BaseDetailComponent, createBaseDetailResolveFn } from '../common/base-detail.component';
+import { BreadcrumbValue } from '../providers/breadcrumb/breadcrumb.service';
+import { ROUTE_COMPONENT_OPTIONS, RouteComponent } from './components/route.component';
+import { RouteComponentOptions } from './types';
+
+export function registerRouteComponent<
+    Component extends any | BaseDetailComponent<Entity>,
+    Entity extends { id: string; updatedAt?: string },
+    T extends DocumentNode | TypedDocumentNode<any, { id: string }>,
+    Field extends keyof ResultOf<T>,
+    R extends Field,
+>(
+    options: {
+        component: Type<Component>;
+        title?: string;
+        breadcrumb?: BreadcrumbValue;
+        path?: string;
+        query?: T;
+        getBreadcrumbs?: (entity: Exclude<ResultOf<T>[R], 'Query'>) => BreadcrumbValue;
+        entityKey?: Component extends BaseDetailComponent<Entity> ? R : undefined;
+        variables?: T extends TypedDocumentNode<any, infer V> ? Omit<V, 'id'> : never;
+        routeConfig?: Route;
+    } & (Component extends BaseDetailComponent<Entity> ? { entityKey: R } : unknown),
+) {
+    const { query, entityKey, variables, getBreadcrumbs } = options;
+
+    const breadcrumbSubject$ = new BehaviorSubject<BreadcrumbValue>(options.breadcrumb ?? '');
+    const titleSubject$ = new BehaviorSubject<string | undefined>(options.title);
+
+    const resolveFn:
+        | ResolveFn<{
+              entity: Observable<ResultOf<T>[Field] | null>;
+              result?: ResultOf<T>;
+          }>
+        | undefined =
+        query && entityKey
+            ? createBaseDetailResolveFn({
+                  query,
+                  entityKey,
+                  variables,
+              })
+            : undefined;
+
+    return {
+        path: options.path ?? '',
+        providers: [
+            {
+                provide: ROUTE_COMPONENT_OPTIONS,
+                useValue: {
+                    component: options.component,
+                    title$: titleSubject$,
+                    breadcrumb$: breadcrumbSubject$,
+                } satisfies RouteComponentOptions,
+            },
+            ...(options.routeConfig?.providers ?? []),
+        ],
+        ...(options.routeConfig ?? {}),
+        resolve: { ...(resolveFn ? { detail: resolveFn } : {}), ...(options.routeConfig?.resolve ?? {}) },
+        data: {
+            breadcrumb: breadcrumbSubject$,
+            ...(options.routeConfig?.data ?? {}),
+            ...(getBreadcrumbs
+                ? {
+                      breadcrumb: data =>
+                          data.detail.entity.pipe(map((entity: any) => getBreadcrumbs(entity))),
+                  }
+                : {}),
+            ...(options.routeConfig?.data ?? {}),
+        },
+        component: RouteComponent,
+    } satisfies Route;
+}

+ 9 - 0
packages/admin-ui/src/lib/core/src/extension/types.ts

@@ -0,0 +1,9 @@
+import { Type } from '@angular/core';
+import { Subject } from 'rxjs';
+import { BreadcrumbValue } from '../providers/breadcrumb/breadcrumb.service';
+
+export interface RouteComponentOptions {
+    component: Type<any>;
+    title$: Subject<string | undefined>;
+    breadcrumb$: Subject<BreadcrumbValue>;
+}

+ 4 - 0
packages/admin-ui/src/lib/core/src/public_api.ts

@@ -74,6 +74,10 @@ export * from './data/utils/add-custom-fields';
 export * from './data/utils/get-server-location';
 export * from './data/utils/remove-readonly-custom-fields';
 export * from './data/utils/transform-relation-custom-field-inputs';
+export * from './extension/components/route.component';
+export * from './extension/providers/page-metadata.service';
+export * from './extension/register-route-component';
+export * from './extension/types';
 export * from './providers/alerts/alerts.service';
 export * from './providers/auth/auth.service';
 export * from './providers/breadcrumb/breadcrumb.service';