Creating Detail Views

The two most common type of components you’ll be creating in your UI extensions are list components and detail components.

In Vendure, we have standardized the way you write these components so that your ui extensions can be made to fit seamlessly into the rest of the app.

Example: Creating a Product Detail View

Let’s say you have a plugin which adds a new entity to the database called ProductReview. You want to create a new list view in the Admin UI which displays all the reviews submitted.

Extend the TypedBaseDetailComponent class

The detail component itself is an Angular component which extends the BaseDetailComponent or TypedBaseDetailComponent class.

import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { TypedBaseDetailComponent, LanguageCode } from '@vendure/admin-ui/core';
import { gql } from 'apollo-angular';

// This is the TypedDocumentNode & type generated by GraphQL Code Generator
import { GetReviewDetailDocument, GetReviewDetailQuery } from './generated-types';

export const GET_REVIEW_DETAIL = gql`
  query GetReviewDetail($id: ID!) {
    review(id: $id) {
      id
      createdAt
      updatedAt
      title
      rating
      text
      authorName
      productId
    }
  }
`;

@Component({
  selector: 'review-detail',
  templateUrl: './review-detail.component.html',
  styleUrls: ['./review-detail.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReviewDetailComponent extends TypedBaseDetailComponent<typeof GetReviewDetailDocument, 'review'> implements OnInit, OnDestroy {
  detailForm = this.formBuilder.group({
    title: [''],
    rating: [1],
    authorName: [''],
  });

  constructor(private formBuilder: FormBuilder) {
    super();
  }

  ngOnInit() {
    this.init();
  }

  ngOnDestroy() {
    this.destroy();
  }

  create() {
    // Logic to save a Review
  }

  update() {
    // Logic to update a Review
  }

  protected setFormValues(entity: NonNullable<GetReviewDetailQuery['review']>, languageCode: LanguageCode): void {
    this.detailForm.patchValue({
      title: entity.name,
      rating: entity.rating,
      authorName: entity.authorName,
      productId: entity.productId,
    });
  }
}

Create the template

Here is the standard layout for detail views:

<vdr-page-header>
  <vdr-page-title></vdr-page-title>
</vdr-page-header>
<vdr-page-body>
  <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>
</vdr-page-body>

Route config

The TypedBaseDetailComponent expects that the entity detail data is resolved as part of loading the route. The data needs to be loaded in a very specific object shape:

interface DetailResolveData {
    detail: {
        entity: Observable<Entity>;
    };
}

Here’s how the routing would look for a typical list & detail view:

import { inject, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { DataService, SharedModule } from '@vendure/admin-ui/core';
import { Observable, of } from "rxjs";
import { map } from 'rxjs/operators';

import { ReviewDetailComponent } from './components/review-detail/review-detail.component';
import { ReviewListComponent } from './components/review-list/review-list.component';
import { GetReviewDocument, GetReviewDetailQuery } from './generated-types';

@NgModule({
  imports: [
    SharedModule,
    RouterModule.forChild([
      // This defines the route for the list view  
      {
        path: '',
        pathMatch: 'full',
        component: ReviewListComponent,
        data: {
          breadcrumb: [
            {
              label: 'Reviews',
              link: [],
            },
          ],
        },
      },
        
      // This defines the route for the detail view  
      {
        path: ':id',
        component: ReviewDetailComponent,
        resolve: {
          detail: route => {
            // Here we are using the DataService to load the detail data
            // from the API. The `GetReviewDocument` is a generated GraphQL
            // TypedDocumentNode.  
            const review$ = inject(DataService)
              .query(GetReviewDocument, { id: route.paramMap.get('id') })
              .mapStream(data => data.review);
            return of({ entity: review$ });
          },
        },
        data: {
          breadcrumb: (
            data: { detail: { entity: Observable<NonNullable<GetReviewDetailQuery['review']>> } },
          ) => data.detail.entity.pipe(
            map((entity) => [
              {
                label: 'Reviews',
                link: ['/extensions', 'reviews'],
              },
              {
                label: `${entity?.title ?? 'New Review'}`,
                link: [],
              },
            ]),
          ),
        },
      },
    ]),
  ],
  declarations: [ReviewListComponent, ReviewDetailComponent],
})
export class ReviewsUiLazyModule {}