Admin UI extension in Vendure

prasmalla

prasanna malla

Posted on April 12, 2023

Admin UI extension in Vendure

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/*"
  ]
}
Enter fullscreen mode Exit fullscreen mode

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}
`;
Enter fullscreen mode Exit fullscreen mode

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"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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,
                                      },
                                  },
                              }
                            : {}),
                    },
                };
            },
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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,
    }),
}),
Enter fullscreen mode Exit fullscreen mode

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!

💖 💪 🙅 🚩
prasmalla
prasanna malla

Posted on April 12, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Admin UI extension in Vendure
typescript Admin UI extension in Vendure

April 12, 2023