## Detail Pages ### Old (Angular) ```ts import { ResultOf } from '@graphql-typed-document-node/core'; import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { TypedBaseDetailComponent, LanguageCode, NotificationService, SharedModule } from '@vendure/admin-ui/core'; // This is the TypedDocumentNode & type generated by GraphQL Code Generator import { graphql } from '../../gql'; export const reviewDetailFragment = graphql(` fragment ReviewDetail on ProductReview { id createdAt updatedAt title rating text authorName productId } `); export const getReviewDetailDocument = graphql(` query GetReviewDetail($id: ID!) { review(id: $id) { ...ReviewDetail } } `); export const createReviewDocument = graphql(` mutation CreateReview($input: CreateProductReviewInput!) { createProductReview(input: $input) { ...ReviewDetail } } `); export const updateReviewDocument = graphql(` mutation UpdateReview($input: UpdateProductReviewInput!) { updateProductReview(input: $input) { ...ReviewDetail } } `); @Component({ selector: 'review-detail', templateUrl: './review-detail.component.html', styleUrls: ['./review-detail.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [SharedModule], }) export class ReviewDetailComponent extends TypedBaseDetailComponent implements OnInit, OnDestroy { detailForm = this.formBuilder.group({ title: [''], rating: [1], authorName: [''], }); constructor(private formBuilder: FormBuilder, private notificationService: NotificationService) { super(); } ngOnInit() { this.init(); } ngOnDestroy() { this.destroy(); } create() { const { title, rating, authorName } = this.detailForm.value; if (!title || rating == null || !authorName) { return; } this.dataService .mutate(createReviewDocument, { input: { title, rating, authorName }, }) .subscribe(({ createProductReview }) => { if (createProductReview.id) { this.notificationService.success('Review created'); this.router.navigate(['extensions', 'reviews', createProductReview.id]); } }); } update() { const { title, rating, authorName } = this.detailForm.value; this.dataService .mutate(updateReviewDocument, { input: { id: this.id, title, rating, authorName }, }) .subscribe(() => { this.notificationService.success('Review updated'); }); } protected setFormValues(entity: NonNullable['review']>, languageCode: LanguageCode): void { this.detailForm.patchValue({ title: entity.name, rating: entity.rating, authorName: entity.authorName, productId: entity.productId, }); } } ``` ```html
``` ```ts import { registerRouteComponent } from '@vendure/admin-ui/core'; import { ReviewDetailComponent, getReviewDetailDocument } from './components/review-detail/review-detail.component'; export default [ registerRouteComponent({ path: ':id', component: ReviewDetailComponent, query: getReviewDetailDocument, entityKey: 'productReview', getBreadcrumbs: entity => [ { label: 'Product reviews', link: ['/extensions', 'product-reviews'], }, { label: `#${entity?.id} (${entity?.product.name})`, link: [], }, ], }), ] ``` ### New (React Dashboard) ```tsx import { DashboardRouteDefinition, detailPageRouteLoader, useDetailPage, Page, PageTitle, PageActionBar, PageActionBarRight, PermissionGuard, Button, PageLayout, PageBlock, FormFieldWrapper, DetailFormGrid, Switch, Input, RichTextInput, CustomFieldsPageBlock, } from '@vendure/dashboard'; import { AnyRoute, useNavigate } from '@tanstack/react-router'; import { toast } from 'sonner'; import { graphql } from '@/gql'; const articleDetailDocument = graphql(` query GetArticleDetail($id: ID!) { article(id: $id) { id createdAt updatedAt isPublished title slug body customFields } } `); const createArticleDocument = graphql(` mutation CreateArticle($input: CreateArticleInput!) { createArticle(input: $input) { id } } `); const updateArticleDocument = graphql(` mutation UpdateArticle($input: UpdateArticleInput!) { updateArticle(input: $input) { id } } `); export const articleDetail: DashboardRouteDefinition = { path: '/articles/$id', loader: detailPageRouteLoader({ queryDocument: articleDetailDocument, breadcrumb: (isNew, entity) => [ { path: '/articles', label: 'Articles' }, isNew ? 'New article' : entity?.title, ], }), component: route => { return ; }, }; function ArticleDetailPage({ route }: { route: AnyRoute }) { const params = route.useParams(); const navigate = useNavigate(); const creatingNewEntity = params.id === 'new'; const { form, submitHandler, entity, isPending, resetForm, refreshEntity } = useDetailPage({ queryDocument: articleDetailDocument, createDocument: createArticleDocument, updateDocument: updateArticleDocument, setValuesForUpdate: article => { return { id: article?.id ?? '', isPublished: article?.isPublished ?? false, title: article?.title ?? '', slug: article?.slug ?? '', body: article?.body ?? '', }; }, params: { id: params.id }, onSuccess: async data => { toast.success('Successfully updated article'); resetForm(); if (creatingNewEntity) { await navigate({ to: `../$id`, params: { id: data.id } }); } }, onError: err => { toast.error('Failed to update article', { description: err instanceof Error ? err.message : 'Unknown error', }); }, }); return ( {creatingNewEntity ? 'New article' : (entity?.title ?? '')} ( )} /> } /> } />
( )} />
); } ``` Important: - The PageBlock component should *never* contain any Card-like component, because it already renders like a card. - Use `refreshEntity` to trigger a manual reload of the entity data (e.g. after a mutation succeeds) - The `DetailFormGrid` has a built-in `mb-6`, but for components not wrapped in this, manually ensure there is a y gap of 6 (e.g. wrap in `
`)