When creating a plugin, we can extend the Admin UI in order to expose a graphical interface to the plugin's functionality.
This is possible by defining AdminUiExtensions. UI extension is an Angular module that gets compiled into the Admin UI application bundle by the compileUiExtensions function exported by the @vendure/ui-devkit
package. Internally, the ui-devkit package makes use of the Angular CLI to compile an optimized set of JavaScript bundles containing your extensions. The Vendure Admin UI is built with Angular, and writing UI extensions in Angular is seamless and powerful. But we can write UI extensions using React, Vue, or other frameworks if not familiar with Angular.
Previously, we extended Vendure with a custom Back-In-Stock notification plugin. Now we add a list component to display the subscriptions in the Vendure Admin UI. TypeScript source files of your UI extensions must not be compiled by your regular TypeScript build task. They will instead be compiled by the Angular compiler with compileUiExtensions()
.
Exclude them in tsconfig.json
by adding a line to the "exclude" array
{
"exclude": [
"src/plugins/**/ui/*"
]
}
Install the ui-devkit package with yarn add @vendure/ui-devkit -D
Using GraphQL schema first approach we write our query so that we can use codegen
to generate the types for the extension
// src/plugins/vendure-plugin-back-in-stock/ui/components/back-in-stock-list.graphql.ts
import gql from 'graphql-tag';
export const BACKINSTOCK_FRAGMENT = gql`
fragment BackInStock on BackInStock {
id
createdAt
updatedAt
status
email
productVariant {
id
name
stockOnHand
}
customer {
id
}
}
`;
export const GET_BACKINSTOCK_SUBSCRIPTION_LIST = gql`
query GetBackInStockSubscriptionList($options: BackInStockListOptions) {
backInStockSubscriptions(options: $options) {
items {
...BackInStock
}
totalItems
}
}
${BACKINSTOCK_FRAGMENT}
`;
Modify codegen.json
to add generated-types
to the generates object and run it with yarn codegen
from the root of the project
// codegen.json
"generated/generated-types.ts": {
"schema": "http://localhost:3000/admin-api",
"documents": "src/plugins/**/ui/**/*.graphql.ts",
"plugins": [
{
"add": {
"content": "/* eslint-disable */"
}
},
"typescript",
"typescript-compatibility",
"typescript-operations"
],
"config": {
"scalars": {
"ID": "string"
}
}
}
Now we can start writing the list component
// src/plugins/vendure-plugin-back-in-stock/ui/components/back-in-stock-list.component.ts
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { BaseListComponent, DataService } from '@vendure/admin-ui/core';
import {
BackInStockSubscriptionStatus,
GetBackInStockSubscriptionList,
SortOrder,
} from '../../../../../generated/generated-types';
import { GET_BACKINSTOCK_SUBSCRIPTION_LIST } from './back-in-stock-list.graphql';
// @ts-ignore
@Component({
selector: 'back-in-stock-list',
templateUrl: './back-in-stock-list.component.html',
styleUrls: ['./back-in-stock-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BackInStockListComponent extends BaseListComponent<
GetBackInStockSubscriptionList.Query,
GetBackInStockSubscriptionList.Items,
GetBackInStockSubscriptionList.Variables
> {
filteredStatus: BackInStockSubscriptionStatus | null = BackInStockSubscriptionStatus.Created;
constructor(private dataService: DataService, router: Router, route: ActivatedRoute) {
super(router, route);
super.setQueryFn(
(...args: any[]) => {
return this.dataService.query<GetBackInStockSubscriptionList.Query>(
GET_BACKINSTOCK_SUBSCRIPTION_LIST,
args,
);
},
data => data.backInStockSubscriptions,
(skip, take) => {
return {
options: {
skip,
take,
sort: {
createdAt: SortOrder.ASC,
},
...(this.filteredStatus != null
? {
filter: {
status: {
eq: this.filteredStatus,
},
},
}
: {}),
},
};
},
);
}
}
And add the template for the list component, for adding custom styles use back-in-stock-list.component.scss
in the same folder as the html template
// src/plugins/vendure-plugin-back-in-stock/ui/components/back-in-stock-list.component.html
<vdr-action-bar>
<vdr-ab-left>
<div class="filter-controls">
<select clrSelect name="status" [(ngModel)]="filteredStatus" (change)="refresh()">
<option [ngValue]="null">All Subscriptions</option>
<option value="Created">Active</option>
<option value="Notified">Notified</option>
</select>
</div>
</vdr-ab-left>
<vdr-ab-right> </vdr-ab-right>
</vdr-action-bar>
<vdr-data-table
[items]="items$ | async"
[itemsPerPage]="itemsPerPage$ | async"
[totalItems]="totalItems$ | async"
[currentPage]="currentPage$ | async"
(pageChange)="setPageNumber($event)"
(itemsPerPageChange)="setItemsPerPage($event)"
>
<vdr-dt-column>ID</vdr-dt-column>
<vdr-dt-column>Status</vdr-dt-column>
<vdr-dt-column>Email</vdr-dt-column>
<vdr-dt-column>Product</vdr-dt-column>
<vdr-dt-column>Created At</vdr-dt-column>
<vdr-dt-column>Updated At</vdr-dt-column>
<ng-template let-subscription="item">
<td class="left align-middle">
{{ subscription.id }}
</td>
<td class="left align-middle">
{{ subscription.status }}
</td>
<td class="left align-middle">
<a
*ngIf="subscription.customer !== null; else guestUser"
[routerLink]="['/customer', 'customers', subscription.customer.id]"
>
{{ subscription.email }}
</a>
<ng-template #guestUser>
{{ subscription.email }}
</ng-template>
</td>
<td class="left align-middle">
<a
title="{{ 'Stock on hand - ' + subscription.productVariant.stockOnHand }}"
[routerLink]="[
'/catalog',
'products',
subscription.productVariant.id,
{ id: subscription.productVariant.id, tab: 'variants' }
]"
>
<clr-icon shape="link"></clr-icon>
{{ subscription.productVariant.name }}
</a>
</td>
<td class="left align-middle">
{{ subscription.createdAt | date : 'mediumDate' }}
</td>
<td class="left align-middle">
{{ subscription.updatedAt | date : 'mediumDate' }}
</td>
</ng-template>
</vdr-data-table>
Let's add the component to it's module
// src/plugins/vendure-plugin-back-in-stock/ui/back-in-stock.module.ts
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { SharedModule } from '@vendure/admin-ui/core';
import { BackInStockListComponent } from './components/back-in-stock-list.component';
// @ts-ignore
@NgModule({
imports: [
SharedModule,
RouterModule.forChild([
{
path: '',
pathMatch: 'full',
component: BackInStockListComponent,
data: { breadcrumb: 'Back-In-Stock Subscriptions' },
},
]),
],
declarations: [BackInStockListComponent],
})
export class BackInStockModule {}
And create a shared module for adding a new section to the Admin UI main nav bar containing a link to the extension
// src/plugins/vendure-plugin-back-in-stock/ui/back-in-stock-shared.module.ts
import { NgModule } from '@angular/core';
import { SharedModule, addNavMenuSection } from '@vendure/admin-ui/core';
// @ts-ignore
@NgModule({
imports: [SharedModule],
providers: [
addNavMenuSection(
{
id: 'back-in-stock',
label: 'Custom Plugins',
items: [
{
id: 'back-in-stock',
label: 'Back-In-Stock',
routerLink: ['/extensions/back-in-stock'],
// Icon can be any of https://core.clarity.design/foundation/icons/shapes/
icon: 'assign-user',
},
],
},
// Add this section before the "settings" section
'settings',
),
],
})
export class BackInStockSharedModule {}
Add the modules to the plugins array in vendure-config.ts
// vendure-config.ts
AdminUiPlugin.init({
route: 'admin',
port: 3002,
adminUiConfig: {
apiHost: 'http://localhost',
apiPort: 3000,
},
app: compileUiExtensions({
outputPath: path.join(__dirname, '../admin-ui'),
extensions: [
{
extensionPath: path.join(__dirname, '../src/plugins/back-in-stock-plugin/ui'),
ngModules: [
{
type: 'lazy' as const,
route: 'back-in-stock',
ngModuleFileName: 'back-in-stock.module.ts',
ngModuleName: 'BackInStockModule',
},
{
type: 'shared' as const,
ngModuleFileName: 'back-in-stock-shared.module.ts',
ngModuleName: 'BackInStockSharedModule',
},
],
},
],
devMode: IS_DEV ? true : false,
}),
}),
Notice devMode
option set to true which will compile the Admin UI app in development mode, and recompile and auto-refresh the browser on any changes to the extension source files.
Angular uses the concept of modules (NgModules) for organizing related code. These modules can be lazily loaded, which means that the code is not loaded when the app starts, but only when that code is required, keeping the main bundle small to improve performance. Shared modules are loaded eagerly, i.e. code is loaded as soon as the app loads. Modules defining new routes must be set to lazy
. Modules defining new navigations items must be set to shared
.
Finally, run with yarn dev
and see the admin UI extension in action at http://localhost:4200/admin/
Read my previous post to learn how the Back-In-Stock plugin was created and join this awesome community of open-source developers on slack to start your own Vendure adventure today!
Top comments (0)