Skip to content

Commit

Permalink
feat(admin-ui): Implement simplified API for UI route extensions
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelbromley committed Sep 5, 2023
1 parent cd093ae commit b9ca367
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 9 deletions.
29 changes: 20 additions & 9 deletions packages/admin-ui/src/lib/core/src/common/base-detail.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -282,9 +298,4 @@ export function detailComponentWithResolver<
);
}
};
return {
resolveFn,
breadcrumbFn: (result: any) => config.getBreadcrumbs?.(result) ?? ([] as BreadcrumbValue[]),
component: config.component,
};
}
Original file line number Diff line number Diff line change
@@ -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),
);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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 changes: 9 additions & 0 deletions packages/admin-ui/src/lib/core/src/extension/types.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions packages/admin-ui/src/lib/core/src/public_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down

0 comments on commit b9ca367

Please sign in to comment.