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<typeof getReviewDetailDocument, 'review'> 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<ResultOf<typeof getReviewDetailDocument>['review']>, languageCode: LanguageCode): void {
this.detailForm.patchValue({
title: entity.name,
rating: entity.rating,
authorName: entity.authorName,
productId: entity.productId,
});
}
}
<vdr-page-block>
<vdr-action-bar>
<vdr-ab-left></vdr-ab-left>
<vdr-ab-right>
<button
class="button primary"
*ngIf="isNew$ | async; else updateButton"
(click)="create()"
[disabled]="detailForm.pristine || detailForm.invalid"
>
{{ 'common.create' | translate }}
</button>
<ng-template #updateButton>
<button
class="btn btn-primary"
(click)="update()"
[disabled]="detailForm.pristine || detailForm.invalid"
>
{{ 'common.update' | translate }}
</button>
</ng-template>
</vdr-ab-right>
</vdr-action-bar>
</vdr-page-block>
<form class="form" [formGroup]="detailForm">
<vdr-page-detail-layout>
<!-- The sidebar is used for displaying "metadata" type information about the entity -->
<vdr-page-detail-sidebar>
<vdr-card *ngIf="entity$ | async as entity">
<vdr-page-entity-info [entity]="entity" />
</vdr-card>
</vdr-page-detail-sidebar>
<!-- The main content area is used for displaying the entity's fields -->
<vdr-page-block>
<!-- The vdr-card is the container for grouping items together on a page -->
<!-- it can also take an optional [title] property to display a title -->
<vdr-card>
<!-- the form-grid class is used to lay out the form fields -->
<div class="form-grid">
<vdr-form-field label="Title" for="title">
<input id="title" type="text" formControlName="title" />
</vdr-form-field>
<vdr-form-field label="Rating" for="rating">
<input id="rating" type="number" min="1" max="5" formControlName="rating" />
</vdr-form-field>
<!-- etc -->
</div>
</vdr-card>
</vdr-page-block>
</vdr-page-detail-layout>
</form>
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: [],
},
],
}),
]
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 <ArticleDetailPage route={route} />;
},
};
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 (
<Page pageId="article-detail" form={form} submitHandler={submitHandler}>
<PageTitle>{creatingNewEntity ? 'New article' : (entity?.title ?? '')}</PageTitle>
<PageActionBar>
<PageActionBarRight>
<PermissionGuard requires={['UpdateProduct', 'UpdateCatalog']}>
<Button
type="submit"
disabled={!form.formState.isDirty || !form.formState.isValid || isPending}
>
Update
</Button>
</PermissionGuard>
</PageActionBarRight>
</PageActionBar>
<PageLayout>
<PageBlock column="side" blockId="publish-status" title="Status" description="Current status of this article">
<FormFieldWrapper
control={form.control}
name="isPublished"
label="Is Published"
render={({ field }) => (
<Switch checked={field.value} onCheckedChange={field.onChange} />
)}
/>
</PageBlock>
<PageBlock column="main" blockId="main-form">
<DetailFormGrid>
<FormFieldWrapper
control={form.control}
name="title"
label="Title"
render={({ field }) => <Input {...field} />}
/>
<FormFieldWrapper
control={form.control}
name="slug"
label="Slug"
render={({ field }) => <Input {...field} />}
/>
</DetailFormGrid>
<div className="space-y-6">
<FormFieldWrapper
control={form.control}
name="body"
label="Content"
render={({ field }) => (
<RichTextInput value={field.value ?? ''} onChange={field.onChange} />
)}
/>
</div>
</PageBlock>
<CustomFieldsPageBlock column="main" entityType="Article" control={form.control} />
</PageLayout>
</Page>
);
}
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 `<div className="space-y-6">`)