Admin UI extension in Vendure
Tags: typescript, graphql, angular, vendure
Published: 2023-04-12 19:16:12

When creating a plugin, we can [extend the Admin UI](https://www.vendure.io/docs/plugins/extending-the-admin-ui/) in order to expose a graphical interface to the plugin's functionality.

This is possible by defining [AdminUiExtensions](https://www.vendure.io/docs/admin-ui-api/ui-devkit/admin-ui-extension/). UI extension is an [Angular module](https://angular.io/guide/ngmodules) that gets compiled into the Admin UI application bundle by the [compileUiExtensions](https://www.vendure.io/docs/admin-ui-api/ui-devkit/compile-ui-extensions/) 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](https://www.vendure.io/docs/plugins/extending-the-admin-ui/using-angular/) is seamless and powerful. But we can write UI extensions using [React, Vue, or other frameworks](https://www.vendure.io/docs/plugins/extending-the-admin-ui/using-other-frameworks/) if not familiar with Angular.

Previously, we [extended Vendure](https://dev.to/prasmalla/extending-vendures-functionality-with-custom-plugins-3l5c) 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

```json
{
  "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

```ts
// 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

```json
// 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

```ts
// 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

```html
// 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

```ts
// 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

```ts
// 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`

```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](https://angular.io/guide/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/](http://localhost:4200/admin/)

Read my [previous post](https://dev.to/prasmalla/extending-vendures-functionality-with-custom-plugins-3l5c) to learn how the Back-In-Stock plugin was created and join this awesome community of open-source developers on [slack](http://vendure.io/slack) to start your own Vendure adventure today!