## Instructions1. If not explicitly stated by the user, find out which plugin they want to migrate.2. Read and understand the overall rules for migration - the "General" section below - the "Common Tasks" section below3. Check the tsconfig setup (see "TSConfig setup" section). This may or may not already be set up. - the "TSConfig setup" section below4. Identify each part of the Admin UI extensions that needs to be migrated, and use the data from the appropriate sections to guide the migration: - the "Forms" section below - the "Custom Field Inputs" section below - the "List Pages" section below - the "Detail Pages" section below - the "Adding Nav Menu Items" section below - the "Action Bar Items" section below - the "Custom Detail Components" section below - the "Page Tabs" section below - the "Widgets" section below5. Ensure you have followed the instructions marked "Important" for each section## General- For short we use "old" to refer to code written for the Angular Admin UI, and "new" for the React Dashboard- old code is usually in a plugin's "ui" dir- new code should be in a plugin's "dashboard" dir- new code imports all components from `@vendure/dashboard`. This includes re-exports of common third-party utilities: - `useQuery`, `useMutation`, `useQueryClient` etc. (TanStack Query) - `Link`, `useNavigate`, `useRouter` etc. (TanStack Router) - `useForm`, `useFormContext`, `Controller` etc. (React Hook Form) - `toast` (Sonner)- The following are imported from their own packages: - hooks or anything else needed from `react` - icons from `lucide-react` - for i18n: `Trans`, `useLingui` from `@lingui/react/macro`- Default to the style conventions of the current project as much as possible (single vs double quotes, indent size etc)## Directory StructureGiven as an example - projects may differ in conventions### Old```- /path/to/plugin - /ui - providers.ts - routes.ts - /components - /example - example.component.ts - example.component.html - example.component.scss - example.graphql.ts```### New```- /path/to/plugin - /dashboard - index.tsx - /components - example.tsx```## Registering extensions### Old```ts title="src/plugins/my-plugin/my.plugin.ts"import * as path from 'path';import { VendurePlugin } from '@vendure/core';import { AdminUiExtension } from '@vendure/ui-devkit/compiler';@VendurePlugin({ // ...})export class MyPlugin { static ui: AdminUiExtension = { id: 'my-plugin-ui', extensionPath: path.join(__dirname, 'ui'), routes: [{ route: 'my-plugin', filePath: 'routes.ts' }], providers: ['providers.ts'], };}```### New```ts title="src/plugins/my-plugin/my.plugin.ts"import { VendurePlugin } from '@vendure/core';@VendurePlugin({ // ... // Note that this needs to match the relative path to the // index.tsx file from the plugin file dashboard: '../dashboard/index.tsx',})export class MyPlugin { // Do not remove any existing AdminUiExtension def // to preserve backward compatibility static ui: AdminUiExtension = { /* ... */ }}```Important: - Ensure the `dashboard` path is correct relative to the locations of the plugin.ts file and the index.ts file## Styling### Oldcustom design system based on Clarity UI```html<button class="button primary">Primary</button><button class="button secondary">Secondary</button><button class="button success">Success</button><button class="button warning">Warning</button><button class="button danger">Danger</button><button class="button-ghost">Ghost</button><a class="button-ghost" [routerLink]="['/extensions/my-plugin/my-custom-route']"> <clr-icon shape="arrow" dir="right"></clr-icon> John Smith</a><button class="button-small">Small</button><button class="button-small"> <clr-icon shape="layers"></clr-icon> Assign to channel</button><clr-icon shape="star" size="8"></clr-icon><img [src]="product.featuredAsset?.preview + '?preset=small'" alt="Product preview" />```### NewTailwind + Vendure Design System. Components import from `@vendure/dashboard````tsximport { Button, DetailPageButton, VendureImage } from '@vendure/dashboard';import { Star } from 'lucide-react';export function MyComponent() { // non-exhaustive - all standard Shadcn props are available return ( <Button variant="default">Primary</Button> <Button variant="secondary">Secondary</Button> <Button variant="outline">Outline</Button> <Button variant="destructive">Danger</Button> <Button variant="ghost">Ghost</Button> <DetailPageButton id="123" label="John Smith" /> <DetailPageButton href="/affiliates/my-custom-route" label="John Smith" /> <Star /> <VendureImage src={entity.product.featuredAsset} alt={entity.product.name} preset='small' /> )} ```Important: - When using `Badge`, prefer variant="secondary" unless especially important data - Where possible avoid specific tailwind colours like `text-blue-600`. Instead use (where possible) the Shadcn theme colours, eg: ``` --color-background --color-foreground --color-primary --color-primary-foreground --color-secondary --color-secondary-foreground --color-muted --color-muted-foreground --color-accent --color-accent-foreground --color-destructive --color-destructive-foreground --color-success --color-success-foreground ``` - Buttons which link to detail pages should use `DetailPageButton`## Data access### Old```tsimport { DataService } from '@vendure/admin-ui/core';import { graphql } from "../gql"; export const GET_CUSTOMER_NAME = graphql(` query GetCustomerName($id: ID!) { customer(id: $id) { id firstName lastName addresses { ...AddressFragment } } }`);this.dataService.query(GET_CUSTOMER_NAME, { id: customerId, }),```### New```tsimport { useQuery, api } from '@vendure/dashboard';import { graphql } from '@/gql';const addressFragment = graphql(` # ...`);const getCustomerNameDocument = graphql(` query GetCustomerName($id: ID!) { customer(id: $id) { id firstName lastName addresses { ...AddressFragment } } }`, [addressFragment]); // Fragments MUST be explicitly referencedconst { data, isLoading, error } = useQuery({ queryKey: ['customer-name', customerId], queryFn: () => api.query(getCustomerNameDocument, { id: customerId }),});```Note on graphql fragments: if common fragments are used across files, you may needto extract them into a common-fragments.graphql.ts file, because with gql.tada they*must* be explicitly referenced in every document that uses them.## Common Tasks### Formatting Dates, Currencies, and Numbers```tsximport {useLocalFormat} from '@vendure/dashboard';// ...// Intl API formatting toolsconst { formatCurrency, formatNumber, formatDate, formatRelativeDate, formatLanguageName, formatRegionName, formatCurrencyName, toMajorUnits, toMinorUnits,} = useLocalFormat();formatCurrency(value: number, currency: string, precision?: number)formatCurrencyName(currencyCode: string, display: 'full' | 'symbol' | 'name' = 'full')formatNumber(value: number) // human-readableformatDate(value: string | Date, options?: Intl.DateTimeFormatOptions)formatRelativeDate(value: string | Date, options?: Intl.RelativeTimeFormatOptions)```### LinksExample link destinations:- Customer detail | <Link to="/customers/$id" params={{ id }}>text</Link>- Customer list | <Link to="/customers">text</Link>- Order detail | <Link to="/orders/$id" params={{ id }}>text</Link>Important: when linking to detail pages, prefer the `DetailPageButton`. If not in a table column,add `className='border'`.## TSConfig setupIf not already set up, we need to make sure we have configured tsconfig with:1. jsx support. Usually create `tsconfig.dashboard.json` like this: ```json { "extends": "./tsconfig.json", "compilerOptions": { "composite": true, "jsx": "react-jsx" }, "include": [ "src/dashboard/**/*.ts", "src/dashboard/**/*.tsx" ] } ``` then reference it from the appropriate tsconfig.json ``` { // ...etc "references": [ { "path": "./tsconfig.dashboard.json" }, ] } ``` This may already be set up (check this). In an Nx-like monorepo where each plugin is a separate project, this will need to be done per-plugin.2. Path mapping. ```json "paths": { // Import alias for the GraphQL types, this needs to point to // the location specified in the vite.config.mts file as `gqlOutputPath` // so will vary depending on project structure "@/gql": ["./apps/server/src/gql/graphql.ts"], // This line allows TypeScript to properly resolve internal // Vendure Dashboard imports, which is necessary for // type safety in your dashboard extensions. // This path assumes a root-level tsconfig.json file. // You may need to adjust it if your project structure is different. "@/vdb/*": [ "./node_modules/@vendure/dashboard/src/lib/*" } ``` In an Nx-like monorepo, this would be added to the tsconfig.base.json or equivalent.## Forms### Old (Angular)```html<div class="form-grid"> <vdr-form-field label="Page title"> <input type="text" /> </vdr-form-field> <vdr-form-field label="Select input"> <select> <option>Option 1</option> <option>Option 2</option> </select> </vdr-form-field> <vdr-form-field label="Checkbox input"> <input type="checkbox" /> </vdr-form-field> <vdr-form-field label="Textarea input"> <textarea></textarea> </vdr-form-field> <vdr-form-field label="Invalid with error"> <input type="text" [formControl]="invalidFormControl" /> </vdr-form-field> <vdr-rich-text-editor class="form-grid-span" label="Description" ></vdr-rich-text-editor></div>```### New (React Dashboard)```tsx<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>;```## Custom Field Inputs### Old (Angular)```ts title="src/plugins/common/ui/components/slider-form-input/slider-form-input.component.ts"import { Component } from '@angular/core';import { FormControl } from '@angular/forms';import { IntCustomFieldConfig, SharedModule, FormInputComponent } from '@vendure/admin-ui/core';@Component({ template: ` <input type="range" [min]="config.min || 0" [max]="config.max || 100" [formControl]="formControl" /> {{ formControl.value }} `, standalone: true, imports: [SharedModule],})export class SliderControlComponent implements FormInputComponent<IntCustomFieldConfig> { readonly: boolean; config: IntCustomFieldConfig; formControl: FormControl;}``````ts title="src/plugins/common/ui/providers.ts"import { registerFormInputComponent } from '@vendure/admin-ui/core';import { SliderControlComponent } from './components/slider-form-input/slider-form-input.component';export default [ registerFormInputComponent('slider-form-input', SliderControlComponent),];```### New (React Dashboard)```tsx title="src/plugins/my-plugin/dashboard/components/color-picker.tsx"import { Button, Card, CardContent, cn, DashboardFormComponent, Input, useFormContext } from '@vendure/dashboard';import { useState } from 'react';// By typing your component as DashboardFormComponent, the props will be correctly typedexport const ColorPickerComponent: DashboardFormComponent = ({ value, onChange, name }) => { const [isOpen, setIsOpen] = useState(false); const { getFieldState } = useFormContext(); const error = getFieldState(name).error; const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57', '#FF9FF3', '#54A0FF', '#5F27CD']; return ( <div className="space-y-2"> <div className="flex items-center space-x-2"> <Button type="button" variant="outline" size="icon" className={cn('w-8 h-8 border-2 border-gray-300 p-0', error && 'border-red-500')} style={{ backgroundColor: error ? 'transparent' : value || '#ffffff' }} onClick={() => setIsOpen(!isOpen)} /> <Input value={value || ''} onChange={e => onChange(e.target.value)} placeholder="#ffffff" /> </div> {isOpen && ( <Card> <CardContent className="grid grid-cols-4 gap-2 p-2"> {colors.map(color => ( <Button key={color} type="button" variant="outline" size="icon" className="w-8 h-8 border-2 border-gray-300 hover:border-gray-500 p-0" style={{ backgroundColor: color }} onClick={() => { onChange(color); setIsOpen(false); }} /> ))} </CardContent> </Card> )} </div> );};``````tsx title="src/plugins/my-plugin/dashboard/index.tsx"import { defineDashboardExtension } from '@vendure/dashboard';import { ColorPickerComponent } from './components/color-picker';defineDashboardExtension({ customFormComponents: { // Custom field components for custom fields customFields: [ { // The "id" is a global identifier for this custom component. We will // reference it in the next step. id: 'color-picker', component: ColorPickerComponent, }, ], }, // ... other extension properties});```## List Pages### Old (Angular)```tsimport { ChangeDetectionStrategy, Component } from '@angular/core';import { TypedBaseListComponent, SharedModule } from '@vendure/admin-ui/core';// This is the TypedDocumentNode generated by GraphQL Code Generatorimport { graphql } from '../../gql';const getReviewListDocument = graphql(` query GetReviewList($options: ReviewListOptions) { reviews(options: $options) { items { id createdAt updatedAt title rating text authorName productId } totalItems } }`);@Component({selector: 'review-list',templateUrl: './review-list.component.html',styleUrls: ['./review-list.component.scss'],changeDetection: ChangeDetectionStrategy.OnPush,standalone: true,imports: [SharedModule],})export class ReviewListComponent extends TypedBaseListComponent<typeof getReviewListDocument, 'reviews'> { // Here we set up the filters that will be available // to use in the data table readonly filters = this.createFilterCollection() .addIdFilter() .addDateFilters() .addFilter({ name: 'title', type: {kind: 'text'}, label: 'Title', filterField: 'title', }) .addFilter({ name: 'rating', type: {kind: 'number'}, label: 'Rating', filterField: 'rating', }) .addFilter({ name: 'authorName', type: {kind: 'text'}, label: 'Author', filterField: 'authorName', }) .connectToRoute(this.route); // Here we set up the sorting options that will be available // to use in the data table readonly sorts = this.createSortCollection() .defaultSort('createdAt', 'DESC') .addSort({name: 'createdAt'}) .addSort({name: 'updatedAt'}) .addSort({name: 'title'}) .addSort({name: 'rating'}) .addSort({name: 'authorName'}) .connectToRoute(this.route); constructor() { super(); super.configure({ document: getReviewListDocument, getItems: data => data.reviews, setVariables: (skip, take) => ({ options: { skip, take, filter: { title: { contains: this.searchTermControl.value, }, ...this.filters.createFilterInput(), }, sort: this.sorts.createSortInput(), }, }), refreshListOnChanges: [this.filters.valueChanges, this.sorts.valueChanges], }); }}``````html<!-- optional if you want some buttons at the top --><vdr-page-block> <vdr-action-bar> <vdr-ab-left></vdr-ab-left> <vdr-ab-right> <a class="btn btn-primary" *vdrIfPermissions="['CreateReview']" [routerLink]="['./', 'create']"> <clr-icon shape="plus"></clr-icon> Create a review </a> </vdr-ab-right> </vdr-action-bar></vdr-page-block><!-- The data table --><vdr-data-table-2 id="review-list" [items]="items$ | async" [itemsPerPage]="itemsPerPage$ | async" [totalItems]="totalItems$ | async" [currentPage]="currentPage$ | async" [filters]="filters" (pageChange)="setPageNumber($event)" (itemsPerPageChange)="setItemsPerPage($event)"> <!-- optional if you want to support bulk actions --> <vdr-bulk-action-menu locationId="review-list" [hostComponent]="this" [selectionManager]="selectionManager" /> <!-- Adds a search bar --> <vdr-dt2-search [searchTermControl]="searchTermControl" searchTermPlaceholder="Filter by title" /> <!-- Here we define all the available columns --> <vdr-dt2-column id="id" [heading]="'common.id' | translate" [hiddenByDefault]="true"> <ng-template let-review="item"> {{ review.id }} </ng-template> </vdr-dt2-column> <vdr-dt2-column id="created-at" [heading]="'common.created-at' | translate" [hiddenByDefault]="true" [sort]="sorts.get('createdAt')" > <ng-template let-review="item"> {{ review.createdAt | localeDate : 'short' }} </ng-template> </vdr-dt2-column> <vdr-dt2-column id="updated-at" [heading]="'common.updated-at' | translate" [hiddenByDefault]="true" [sort]="sorts.get('updatedAt')" > <ng-template let-review="item"> {{ review.updatedAt | localeDate : 'short' }} </ng-template> </vdr-dt2-column> <vdr-dt2-column id="title" heading="Title" [optional]="false" [sort]="sorts.get('title')"> <ng-template let-review="item"> <a class="button-ghost" [routerLink]="['./', review.id]" ><span>{{ review.title }}</span> <clr-icon shape="arrow right"></clr-icon> </a> </ng-template> </vdr-dt2-column> <vdr-dt2-column id="rating" heading="Rating" [sort]="sorts.get('rating')"> <ng-template let-review="item"><my-star-rating-component [rating]="review.rating" /></ng-template> </vdr-dt2-column> <vdr-dt2-column id="author" heading="Author" [sort]="sorts.get('authorName')"> <ng-template let-review="item">{{ review.authorName }}</ng-template> </vdr-dt2-column></vdr-data-table-2>``````tsimport { registerRouteComponent } from '@vendure/admin-ui/core';import { ReviewListComponent } from './components/review-list/review-list.component';export default [ registerRouteComponent({ path: '', component: ReviewListComponent, breadcrumb: 'Product reviews', }),]```### New (React Dashboard)```tsximport { Button, DashboardRouteDefinition, ListPage, PageActionBarRight, DetailPageButton, Link,} from '@vendure/dashboard';import { PlusIcon } from 'lucide-react';// This function is generated for you by the `vendureDashboardPlugin` in your Vite config.// It uses gql-tada to generate TypeScript types which give you type safety as you write// your queries and mutations.import { graphql } from '@/gql';// The fields you select here will be automatically used to generate the appropriate columns in the// data table below.const getArticleList = graphql(` query GetArticles($options: ArticleListOptions) { articles(options: $options) { items { id createdAt updatedAt isPublished title slug body customFields } totalItems } }`);const deleteArticleDocument = graphql(` mutation DeleteArticle($id: ID!) { deleteArticle(id: $id) { result } }`);export const articleList: DashboardRouteDefinition = { navMenuItem: { sectionId: 'catalog', id: 'articles', url: '/articles', title: 'CMS Articles', }, path: '/articles', loader: () => ({ breadcrumb: 'Articles', }), component: route => ( <ListPage pageId="article-list" title="Articles" listQuery={getArticleList} deleteMutation={deleteArticleDocument} route={route} customizeColumns={{ title: { cell: ({ row }) => { const post = row.original; return <DetailPageButton id={post.id} label={post.title} />; }, }, }} defaultVisibility={{ type: true, summary: true, state: true, rating: true, authorName: true, authorLocation: true, }} defaultColumnOrder={[ 'type', 'summary', 'authorName', 'authorLocation', 'rating', ]} > <PageActionBarRight> <Button render={<Link to="./new" />}> <PlusIcon className="mr-2 h-4 w-4" /> New article </Button> </PageActionBarRight> </ListPage> ),};```Important: - When using `defaultVisibility`, specify the specific visible ones with `true`. *Do not* mix true and false values. It is implicit that any not specified will default to `false`. - The `id`, `createdAt` and `updatedAt` never need to be specified in `customizeColumns`, defaultVisibility` or `defaultColumnOrder`. They are handled correctly by default. - By default the DataTable will handle column names based on the field name, e.g. `authorName` -> `Author Name`, `rating` -> `Rating`, so an explicit cell header is not needed unless the column header title must significantly differ from the field name. - If a custom `cell` function needs to access fields _other_ than the one being rendered, those other fields *must* be declared as dependencies: ```tsx customizeColumns={{ name: { // Note, we DO NOT need to declare "name" as a dependency here, // since we are handling the `name` column already. meta: { dependencies: ['reviewCount'] }, cell: ({ row }) => { const { name, reviewCount } = row.original; return <Badge variant="outline">{name} ({reviewCount})</Badge> }, }, }} ```## Detail Pages### Old (Angular)```tsimport { 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 Generatorimport { 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, }); }}``````html<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>``````tsimport { 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)```tsximport { DashboardRouteDefinition, detailPageRouteLoader, useDetailPage, Page, PageTitle, PageActionBar, PageActionBarRight, PermissionGuard, Button, PageLayout, PageBlock, FormFieldWrapper, DetailFormGrid, Switch, Input, RichTextInput, CustomFieldsPageBlock, useNavigate, toast,} from '@vendure/dashboard';import type { AnyRoute } from '@vendure/dashboard';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">`)## Adding Nav Menu Items### Old (Angular)```tsimport { addNavMenuSection } from '@vendure/admin-ui/core';export default [ addNavMenuSection({ id: 'greeter', label: 'My Extensions', items: [{ id: 'greeter', label: 'Greeter', routerLink: ['/extensions/greet'], // Icon can be any of https://core.clarity.design/foundation/icons/shapes/ icon: 'cursor-hand-open', }], }, // Add this section before the "settings" section 'settings'),];```### New (React Dashboard)```tsximport { defineDashboardExtension } from '@vendure/dashboard';defineDashboardExtension({ routes: [ { path: '/my-custom-page', component: () => <div>My Custom Page</div>, navMenuItem: { // The section where this item should appear sectionId: 'catalog', // Unique identifier for this menu item id: 'my-custom-page', // Display text in the navigation title: 'My Custom Page', // Optional: URL if different from path url: '/my-custom-page', }, }, ],});```## Action Bar Items### Old (Angular)```tsimport { addActionBarItem } from '@vendure/admin-ui/core';export default [ addActionBarItem({ id: 'print-invoice', locationId: 'order-detail', label: 'Print invoice', icon: 'printer', routerLink: route => { const id = route.snapshot.params.id; return ['./extensions/order-invoices', id]; }, requiresPermission: 'ReadOrder', }),];```### New (React Dashboard)```tsximport { Button, defineDashboardExtension } from '@vendure/dashboard';import { useState } from 'react';defineDashboardExtension({ actionBarItems: [ { pageId: 'product-detail', component: ({ context }) => { const [count, setCount] = useState(0); return ( <Button type="button" variant="secondary" onClick={() => setCount(x => x + 1)}> Counter: {count} </Button> ); }, }, ],});```## Custom Detail Components### Old (Angular)```ts title="src/plugins/cms/ui/components/product-info/product-info.component.ts"import { Component, OnInit } from '@angular/core';import { Observable, switchMap } from 'rxjs';import { FormGroup } from '@angular/forms';import { DataService, CustomDetailComponent, SharedModule } from '@vendure/admin-ui/core';import { CmsDataService } from '../../providers/cms-data.service';@Component({ template: ` <vdr-card title="CMS Info"> <pre>{{ extraInfo$ | async | json }}</pre> </vdr-card>`, standalone: true, providers: [CmsDataService], imports: [SharedModule],})export class ProductInfoComponent implements CustomDetailComponent, OnInit { // These two properties are provided by Vendure and will vary // depending on the particular detail page you are embedding this // component into. In this case, it will be a "product" entity. entity$: Observable<any> detailForm: FormGroup; extraInfo$: Observable<any>; constructor(private cmsDataService: CmsDataService) { } ngOnInit() { this.extraInfo$ = this.entity$.pipe( switchMap(entity => this.cmsDataService.getDataFor(entity.id)) ); }}```### New (React Dashboard)```tsx title="src/plugins/my-plugin/dashboard/index.tsx"import { defineDashboardExtension } from '@vendure/dashboard';defineDashboardExtension({ pageBlocks: [ { id: 'related-articles', title: 'Related Articles', location: { // This is the pageId of the page where this block will be pageId: 'product-detail', // can be "main" or "side" column: 'side', position: { // Blocks are positioned relative to existing blocks on // the page. blockId: 'facet-values', // Can be "before", "after" or "replace" // Here we'll place it after the `facet-values` block. order: 'after', }, }, component: ({ context }) => { // In the component, you can use the `context` prop to // access the entity and the form instance. return <div className="text-sm">Articles related to {context.entity.name}</div>; }, }, ],});```## Page Tabs### Old (Angular)```tsimport { registerPageTab } from '@vendure/admin-ui/core';import { ReviewListComponent } from './components/review-list/review-list.component';export default [ registerPageTab({ location: 'product-detail', tab: 'Reviews', route: 'reviews', tabIcon: 'star', component: ReviewListComponent, }),];```### New (React Dashboard)Page tabs are not supported by the Dashboard. Suggest alternative such as a new route.## Widgets### Old (Angular)```ts title="src/plugins/reviews/ui/components/reviews-widget/reviews-widget.component.ts"import { Component, OnInit } from '@angular/core';import { DataService, SharedModule } from '@vendure/admin-ui/core';import { Observable } from 'rxjs';@Component({ selector: 'reviews-widget', template: ` <ul> <li *ngFor="let review of pendingReviews$ | async"> <a [routerLink]="['/extensions', 'product-reviews', review.id]">{{ review.summary }}</a> <span class="rating">{{ review.rating }} / 5</span> </li> </ul> `, standalone: true, imports: [SharedModule],})export class ReviewsWidgetComponent implements OnInit { pendingReviews$: Observable<any[]>; constructor(private dataService: DataService) {} ngOnInit() { this.pendingReviews$ = this.dataService.query(gql` query GetAllReviews($options: ProductReviewListOptions) { productReviews(options: $options) { items { id createdAt authorName summary rating } } }`, { options: { filter: { state: { eq: 'new' } }, take: 10, }, }) .mapStream(data => data.productReviews.items); }}``````ts title="src/plugins/reviews/ui/providers.ts"import { registerDashboardWidget } from '@vendure/admin-ui/core';export default [ registerDashboardWidget('reviews', { title: 'Latest reviews', supportedWidths: [4, 6, 8, 12], requiresPermissions: ['ReadReview'], loadComponent: () => import('./reviews-widget/reviews-widget.component').then( m => m.ReviewsWidgetComponent, ), }),];```### New (React Dashboard)```tsx title="custom-widget.tsx"import { Badge, DashboardBaseWidget, useLocalFormat, useWidgetFilters } from '@vendure/dashboard';export function CustomWidget() { const { dateRange } = useWidgetFilters(); const { formatDate } = useLocalFormat(); return ( <DashboardBaseWidget id="custom-widget" title="Custom Widget" description="This is a custom widget"> <div className="flex flex-wrap gap-1"> <span>Displaying results from</span> <Badge variant="secondary">{formatDate(dateRange.from)}</Badge> <span>to</span> <Badge variant="secondary">{formatDate(dateRange.to)}</Badge> </div> </DashboardBaseWidget> );}``````tsx title="index.tsx"import { defineDashboardExtension } from '@vendure/dashboard';import { CustomWidget } from './custom-widget';defineDashboardExtension({ widgets: [ { id: 'custom-widget', name: 'Custom Widget', component: CustomWidget, defaultSize: { w: 3, h: 3 }, }, ],});```