1
0

05-detail-pages.md 12 KB

Detail Pages

Old (Angular)

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: [],
            },
        ],
    }),
]

New (React Dashboard)

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">`)