Skip to content

aghiadodeh/Angular-PrimeNG-CMS-Dashboard

Repository files navigation

Angular PrimeNg CMS Dashboard

Manage repetitive CRUDs Operations with a few lines depending on PrimeNg and PrimeFlex

Angular Version:

17.1.0

Example:

to run example:

npm install
npx nx run angular-core:serve --configuration=development

Screenshots:

Features:

  1. Generic Filters Builder
  2. Generic Form Builder
  3. Manage State
  4. Caching
  5. Display items with table or with custom view
  6. Manage Base CRUD Actions (Create, Update, View and Delete), with ability to add custom actions

Installation:

npm install @x-angular/cms

Setup:

CMS library use Angular InjectionToken to provide the environment configurations like API_URL as a dependency, You can inject your environment configurations globally in the src/app/app.config.ts:

import { CMS_CONFIGURATION } from '@x-angular/cms';
import { HttpCacheInterceptorModule } from '@ngneat/cashew';
import { DynamicDialogConfig } from "primeng/dynamicdialog";

const dialogConfig: DynamicDialogConfig = {
  width: '50vw',
  contentStyle: { overflow: 'auto' },
  breakpoints: {
    '960px': '75vw',
    '640px': '90vw',
  },
};

export const appConfig: ApplicationConfig = {
  providers: [
    ...,
    provideHttpClient(),
    {
      provide: CMS_CONFIGURATION,
      useValue: {
        CMS_API_URL: "https://www.development.com/api", // Your backend api URL
        CMS_PAGE_SIZE: 15, // default page size when get data with pagination
        DIALOG_CONFIGURATION: dialogConfig // dialog configuration for create/update entity
      },
    },
    importProvidersFrom([
      ...,
      HttpCacheInterceptorModule.forRoot(),
    ]),
  ],
};
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <!-- add prime theme here -->
    <link id="app-theme" rel="stylesheet" type="text/css" href="lara-light.css">
  </head>
  <body>
    <!-- root -->
  </body>
</html>
// angular.json
{
  "projects": {
      // ...
      "root": "",
      "sourceRoot": "src",
      "prefix": "app",
      "architect": {
        "build": {
          "options": {
            // ...
            "styles": [
              "src/styles.scss",
              "node_modules/@x-angular/cms/styles/prime.scss", // <-- add styles here
              "node_modules/@x-angular/cms/styles/global.scss", // <-- add styles here
              {
                "input": "node_modules/primeng/resources/themes/lara-light-blue/theme.css",  // <-- add styles here
                "bundleName": "lara-light",
                "inject": false
              },
              {
                "input": "node_modules/primeng/resources/themes/lara-dark-blue/theme.css",  // <-- add styles here
                "bundleName": "lara-dark",
                "inject": false
              }
            ]
          },
        }
      }
  }
}

Create new CRUD:

If you to create new CRUD (products CRUD) you can achieve this by:

  1. Decalre Your Product Model:
export interface Product {
    id?: number;
    title?: string;
    description?: string;
    price?: number;
    discountPercentage?: number;
    rating?: number;
    stock?: number;
    brand?: string;
    category?: string;
    thumbnail?: string;
    createdAt?: string | null;
}
  1. Declare @Injecable Service ProductService which extends CmsService from CMS library:
import { Injectable } from "@angular/core";
import { CmsService } from '@x-angular/cms';
import { Product } from "src/app/models/product.model";

@Injectable()
export class ProductService extends CmsService<Product> {
    constructor() {
        super();
    }

    public override crudConfiguration: CRUDConfiguration<Product> = {
        endPoints: {
            index: 'products', // <-- products resource name in the backend
            // create: 'products/new', // <-- create new product endPoint, default is same index (`products`)
            // view: (id: string) => `products/view/${id}`, // <-- find product by id, default: `products/${id}`
            // update: (id: string) => `products/update/${id}`, // <-- update product, default: `products/${id}`
            // remove: (id: string) => `products/remove/${id}`, // <-- remove product, default: `products/${id}`
        },
        tableConfiguration: {
            dataKey: 'id', // property to uniquely identify a record in data
            columns: [
                {
                    title: "ID",
                    key: "id",
                    sortKey: 'id',
                    ngStyle: {'color': 'yellow'}, // customize style
                },
                {
                    title: "name", // displayed label in the tablet header (translated by @ngx-translate)
                    key: "title", // the key you want to display from your model
                    sortKey: 'title', // sorting key which will be sent to backend in params for sorting data
                },
                {
                    title: "price",
                    key: "price",
                    sortKey: 'price'
                },
                { 
                    title: "rating", 
                    key: "rating",
                },
                { 
                    title: "createdAt", 
                    key: "createdAt"
                },
            ],
        },
    };
}

the index in endPoints is the products resource name in the backend, so when CmsService fetch the data it will call ${CMS_API_URL}/${endPoints.index}, in our example it will be https://www.development.com/api/products

  1. Declare Your ProductService in your component providers:
// src/app/dashboard/modules/products/products.component.ts
import { Component } from '@angular/core';
import { ProductService } from './services/products.service';
import { CmsService, CmsListComponent } from '@x-angular/cms';
import { Product } from "src/app/models/product.model";

@Component({
  ...,
  standalone: true,
  imports: [
    CmsListComponent,
  ],
  providers: [
    ProductService,
    {
      provide: CmsService<Product>,
      useExisting: ProductService,
    },
  ]
})
export class ProductsComponent {}
<!-- src/app/dashboard/modules/products/products.component.html -->
<cms-list />

You will see the table and paginator appear in your page

Customize Table <td>:

You can customize any table column by passing map of ng-template to cms-list,

Note: the map entry key is the same column key in the tableConfiguration columns, and the entry value is the template ref.

Let's display Product rating as rating-stars, and add to table the product image:

  1. Add templateRef: true to rating column to tell cms-table that the rating column will be ng-template
export class ProductService extends CmsService<Product> {
    public override crudConfiguration: CRUDConfiguration<Product> = {
        tableConfiguration: {
            // ...,
            columns: [
                {
                    title: "image", 
                    key: "thumbnail",
                    templateRef: true, // <-- add here
                },
                // ...,
                { 
                    title: "rating", 
                    key: "rating",
                    templateRef: true, // <-- add here
                },
                // ...,
            ],
        },
    };
}
  1. Create your ng-template inside your html file and pass it to cms-list
<cms-list [templates]="{
    'thumbnail': userImageTemplate,
    'rating': ratingImageTemplate,
}" />

<!-- product image -->
<ng-template let-product #userImageTemplate>
    <div class="product-table-image">
        <img [src]="product.thumbnail">
    </div>
</ng-template>

<!-- product rating -->
<ng-template let-product #ratingImageTemplate>
    <div class="product-table-rating">
        <!-- https://primeng.org/rating -->
        <p-rating [readonly]="true" [cancel]="false" [(ngModel)]="product.rating" />
    </div>
</ng-template>

Display items in custom view instead of table:

If you want to display the fetched items in custom view, you should pass custom content to cms-list

<cms-list>
        <div class="flex flex-column pb-3" custom>
            <h1>Products</h1>
            <div class="flex flex-wrap w-full gap-3">
                @if (productService.result$ | async; as result) {
                @for (product of result.data; track product.id) {
                <div class="custom-product-card w-full">
                    <p-card class="flex flex-column w-full gap-2">
                        <img [src]="product.thumbnail" width="100%" height="200" />
                        <span>{{ product.title }}</span>
                    </p-card>
                </div>
                }
                }
            </div>
        </div>
</cms-list>

Disable cache:

@Injectable()
export class ProductService extends CmsService<Product> {
   override withCache: boolean = false;
}

Invalidate cache:

You can reset cache by call invalidateCache method:

@Injectable()
export class ProductService extends CmsService<Product> {
    public doSomething(): void {
        // logic...
        this.invalidateCache();
    }
}

Mapping data:

You can map your model as you wish by override mapFetchedData method,

In our example, We want to format the createdAt and round the rating value:

import { DatePipe } from "@angular/common";

@Injectable()
export class ProductService extends CmsService<Product> {
    constructor(private datePipe: DatePipe) {
        super();
    }

    public override mapFetchedData = (data: Product[]): Product[] => {
        data.forEach(product => {
            product.rating = Math.round(product.rating ?? 0);
            product.createdAt = this.datePipe.transform(product.createdAt, "yyyy-MM-dd hh:mm a")
        });
        return data;
    };
}

Mapping Http Response:

CmsService expect to receive a BaseResponse<T>:

export interface BaseResponse<T> {
  message?: string;
  success?: boolean;
  data: T;
  statusCode?: number;
}

If your backend return a different response, You can mapping the response to BaseResponse<T>:

@Injectable()
export class ProductService extends CmsService<Product> {

   override mapResponse<T>(response: HttpResponse<T>): BaseResponse<T> {
        const body: any = response ?? {};
        return {
            data: body.data,
            statusCode: body.status_code,
            success: body.status,
            message: body.msg,
        };
    }
}

Manage CMS Actions visiblity:

export const importMimetype = '.csv, text/csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';

@Injectable()
export class ProductService extends CmsService<Product> {
    public override crudConfiguration: CRUDConfiguration<Product> = {
        actions: {
            create: () => true, // currentUser.hasPermission('create-product')
            delete: () => true,
            export: () => true,
            selectRows: () => true,
            import: () => {
                return { accept: importMimetype, label: 'import' }
            },
        },
    }
}
<cms-list>
    <!-- add view between filters and table -->
    <div class="w-full flex justify-content-between align-items-center p-3" header>
        <h1>Header</h1>
    </div>

    <!-- custom table start actions -->
    <div tableStartActions>
        <p-button severity="info" [outlined]="true">
            <span>Custom Action 1</span>
        </p-button>
    </div>

    <!-- custom table end actions -->
    <div tableEndActions>
        <p-button severity="danger" [outlined]="true" [rounded]="true">
            <span>Custom Action 2</span>
        </p-button>
    </div>
</cms-list>

Cell Action:

You can observe on some cell action by:

  1. Set the column clickable as true in your tableConfiguration:
export class ProductService extends CmsService<Product> {
    public override crudConfiguration: CRUDConfiguration<Product> = {
        tableConfiguration: {
            // ...,
            columns: [
                {
                    title: "image", 
                    key: "thumbnail",
                    templateRef: true,
                    clickable: true, // <-- add here
                },
                // ...,
            ],
        },
    };
}
  1. Observe on Action in your component:
export class ProductsListComponent {
  constructor(public productsService: ProductService) {
    productsService.cellAction$.subscribe((action: BaseCellEvent<Product>) => {
      const {
        key, // "thumbnail"
        item // row data
      } = action;
      console.log(action);
    });
  }
}

Row Actions:

CMS library provide you with main CRUD actions View, Update and Delete, but can add any custom action as you wish.

Action Model:

export interface BaseTableColumnAction {
    key: any; // observe emit action by this key
    label?: string;
    icon?: string; // see https://primeng.org/icons
    visible?: boolean;
    visibleFn?: () => boolean;
    severity?: 'secondary' | 'success' | 'info' | 'warning' | 'help' | 'danger';
}

Add Actions to cms-table:

@Injectable()
export class ProductService extends CmsService<Product> {
    public override crudConfiguration: CRUDConfiguration<Product> = {
        tableConfiguration: {
            // ...
            columns: [
                // ...
            ],
            actions: (item: Product) => [
                {
                    key: CmsActionEnum.view,
                    label: 'view', // translated by @ngx-translate
                    icon: 'pi pi-eye', // https://primeng.org/icons
                    severity: 'success',
                    visible: item.id != 1, // some condition
                },
                {
                    key: CmsActionEnum.update,
                    label: 'update',
                    icon: 'pi pi-pencil',
                },
                {
                    key: 'product-custom_action', // <-- custom action
                    label: 'custom_action',
                    icon: 'pi pi-bolt',
                    severity: 'info',
                },
                {
                    key: CmsActionEnum.delete,
                    label: 'delete',
                    icon: 'pi pi-trash',
                    severity: 'danger',
                },
            ],
        },
    }
}

View, Update and Delete Actions handled by cms-list, so no need to observe these actions from your component.

Observe Custom Action:

You can detect when user click on product-custom_action:

export class ProductsListComponent {
  constructor(productsService: ProductService) {
    productsService.rowAction$.subscribe((action: BaseRowEvent<Product>) => {
      const {
        key, // 'product-custom_action'
        item // row data
      } = action;
      console.log(action);
    });
  }
}

Delete Action:

You only should decalre in your translate json file these keys:

{
    "delete_confirmation": "Delete Confirmation",
    "delete_confirmation_message": "Do you want to delete this record?"
}

View Action:

Call Request to find item details by dataKey and display it.

  1. Add Route for view-details component:
export const productsRoutes: Route[] = [
    {
        path: 'products',
        loadComponent: () => import('./products.component').then(e => e.ProductsComponent),
        children: [
            {
                path: '',
                loadComponent: () => import('./modules/products-list/products-list.component').then(e => e.ProductsListComponent),
            },
            {
                path: "view/:id", // "view-details/:id"
                loadComponent: () => import('./modules/product-details/product-details.component').then(e => e.ProductDetailsComponent),
            },
        ]
    },
];
  1. Navigate to Specific route (optional): default route is view/:id but if you want to change this value you can override viewDetailsRoute:
@Injectable()
export class ProductService extends CmsService<Product> {
    override viewDetailsRoute = (item: Product) => `view-details/${item.id}`;
}
  1. Custom findById server endPoint (optional): By default find by id end-point is ${endPoints.index}/${item.id},

But you can override the endPoint by:

export class ProductService extends CmsService<Product> {
    public override crudConfiguration: CRUDConfiguration<Product> = {
        endPoints: {
            index: 'products',
            view: (id: any) => `products/view/${id}`, // <-- add here
        },
    }
}
  1. Setup Your ViewDetails Component:

product-details.component.ts:

import { CmsViewDetailsComponent, ViewDetailsComponent } from '@x-angular/cms';

@Component({
  // ...,
  imports: [CmsViewDetailsComponent],
})
export class ProductDetailsComponent extends ViewDetailsComponent<Product> {
  override title = (item: Product) => item.title ?? ""; // Page title
}

Extending ViewDetailsComponent will send request to server for get item details by dataKey depending on route params.

product-details.component.html:

<cms-view-details [result]="result"> <!-- result is the fetched data from ViewDetailsComponent -->
    <div class="product-view-details-container" content> <!-- `content` is ng-content selector name -->
        @if (result.data$ | async; as data) {
        <div class="flex flex-column">
            <!-- Your Product layout -->
        </div>
        }
    </div>
</cms-view-details>

Export Action (download file):

@Injectable()
export class ProductService extends CmsService<Product> {
    // download file
    override exportFile(): Observable<any> {
        this.exporting$.next(true);
        return this.download(`${this.endPoints.index}/csv`, `${this.endPoints.index}-${new Date()}`, 'csv').pipe(
            finalize(() => this.exporting$.next(false)),
        );
    }
}

Import Action (upload file):

@Injectable()
export class ProductService extends CmsService<Product> {
    public override crudConfiguration: CRUDConfiguration<Product> = {
        endPoints: {
            // ...
            importFile: (file: File) => { // define import endPoint, mapping request formData
                return { endPoint: 'products/import', requestBody: { media: file }, auto: true };
            },
        },
    }
}

Delete Multiple Rows:

@Injectable()
export class ProductService extends CmsService<Product> {
    public override crudConfiguration: CRUDConfiguration<Product> = {
        endPoints: {
            // ...
            removeMultiple: (items: Product[]) => { // delete multiple rows endPoint, mapping selected rows request data
                return { endPoint: 'products/delete', requestBody: { ids: items.map(item => item.id) } };
            },
        },
    }
}

Update Action (Generic Form Builder):

Update action has two types

  1. dialog
  2. page
@Injectable()
export class ProductService extends CmsService<Product> {
    public override crudConfiguration: CRUDConfiguration<Product> = {
        openFormType: 'dialog',
        // ...
    }
}

both types require formSchema

Note: If you didn't set openFormType value, the page/dialog will not open, and you should handle rowAction$ event manually.

Generic Form Create/Update

Setup FormSchema:

@Injectable()
export class ProductService extends CmsService<Product> {
    private categoryDisabled = new BehaviorSubject(true);

    public override formSchema: FormSchema<Product> = {
        ngClass: 'products-form-container flex flex-wrap gap-3 align-items-center justify-content-center xl:justify-content-start', // manage your custom styles
        parseToFormData: true, // send request as FormData
        fetchItemForUpdate: true, // fetch item by id from server before display the update form
        staticData: { /* inject static data in request body */ },
        inputs: (item?: Product) => [ // item is nullable value, in create mode it will be null and in update mode it will be the updated item
            {
                key: 'media',
                label: 'thumbnail',
                value: item?.thumbnail,
                inputType: FormInputType.image,
                validators: item ? [] : [Validators.required],
                imageConfiguration: {
                    path: item?.thumbnail,
                    type: 'rounded', // rounded/circle
                }
            },
            {
                key: 'title',
                label: 'name',
                value: item?.title,
                inputType: FormInputType.text,
                validators: [Validators.required],
            },
            {
                key: 'price',
                label: 'price',
                value: item?.price ?? 0,
                inputType: FormInputType.number,
                validators: [Validators.required, Validators.min(1)],
                numberConfiguration: {
                    currency: 'USD',
                    mode: 'currency',
                }
            },
            {
                key: 'discountPercentage',
                label: 'discountPercentage',
                value: item?.discountPercentage ?? 0,
                inputType: FormInputType.number,
                validators: [Validators.min(0)],
                numberConfiguration: {
                    suffix: '%',
                }
            },
            {
                key: 'brand',
                label: 'brand',
                value: item?.brand,
                inputType: FormInputType.dropdown,
                validators: [Validators.required],
                onChange: (value: any) => {
                    // disable the category input when brand is null
                    this.categoryDisabled.next(value == null);
                },
                dropdownConfiguration: {
                    filterBy: 'brand', // filter by object key
                    valueBy: 'id', // set formControl value with object value
                    optionLabel: 'brand', // display suggestions by option-label
                    options: [], // dropdown suggestions
                    indexFn: (items: any[]) => items.findIndex(e => e.brand == item?.brand), // dropdown default suggestion index
                    remoteDataConfiguration: { // fetch suggestions from server
                        endPoint: 'products/brands',
                        mapHttpResponse: (response: any) => response.data,
                    }
                },
            },
            {
                key: 'category',
                label: 'category',
                value: item?.category,
                inputType: FormInputType.autocomplete,
                validators: [Validators.required],
                disabled$: this.categoryDisabled, // disable the input
                autoCompleteConfiguration: {
                    filterBy: 'category',
                    valueBy: 'id',
                    optionLabel: 'category',
                    options: [],
                    dropdown: true,
                    indexFn: (items: any[]) => items.findIndex(e => e.category == item?.category),
                    remoteDataConfiguration: {
                        endPoint: 'products/categories',
                        mapHttpResponse: (response: any) => response.data,
                    }
                },
            },
        ],
    };
}

Note: Don't forget to add your routes for openFormType: 'page'

FormInput Types:

  • image
  • file
  • text
  • email
  • password
  • number
  • date
  • checkbox
  • triStateCheckbox
  • radio
  • time
  • color
  • dropdown
  • autocomplete
  • multiSelect

Setup open form type with page:

  1. Add your create/update routes, default: ("new", "update/:id")
  2. Customize Your routes (optional):
@Injectable()
export class ProductService extends CmsService<Product> {
    public override formSchema: FormSchema<Product> = {
        routes: { create: 'new-product', update: (item: Product) => `update-product/${item.id}` },
        // ...
    };
}

Generic Filters

Decalring filterSchema will make cms-filters appears automatically.

@Injectable()
export class ProductService extends CmsService<Product> {
    private categoryDisabled = new BehaviorSubject(true);

    // disable fetching data in cms-list ngOnInit, use this option when you want to fetch data depending on some event like filter on specific data
    override fetchDataOnInitialize: boolean = false;

    public override crudConfiguration: CRUDConfiguration<User> = {
        openFilterAccordion: false, // make filter accordion closed by default
    }

    public override filterSchema: FilterSchema = {
        inputs: [
            {
                key: 'name',
                label: 'name',
                // value: currentUser.name,
                inputType: FilterInputType.text,
            },
            {
                key: 'brand',
                label: 'brand',
                inputType: FilterInputType.dropdown,
                dropdownConfiguration: {
                    // ...,
                    onChange: (value: any) => {
                        // disable the category input when brand is null
                        this.categoryDisabled.next(value == null);
                    },
                },
            },
            {
                key: 'category',
                label: 'category',
                inputType: FilterInputType.dropdown,
                disabled$: this.categoryDisabled,
                dropdownConfiguration: // ...,
            },
        ],
        filterDto: {
            per_page: 25, // override global config
            sortKey: 'id', // sort by id
            sortDir: 'ASC', // sort ascending
        },
    };
}

FilterInput Types:

  • text
  • email
  • number
  • date
  • time
  • search (display 🔎 icon with input)
  • dropdown
  • multiSelect

Customize Generic Filters

You Can use cms-generic-filters instead of default cms-filters

<cms-list>
    <!-- custom filters layout -->
    <div class="flex flex-column w-full" filters>
        <cms-generic-filters [filterSchema]="productsService.filterSchema">
            <div class="w-full flex justify-content-end align-items-center pt-3" actions>
                <!-- custom actions -->
                <p-button severity="success" [text]="true">
                    <span>{{ 'custom_action' | translate }}</span>
                </p-button>

                <!-- reset filters -->
                <p-button severity="danger" [text]="true" (onClick)="resetFilters()">
                    <span>{{ 'reset' | translate }}</span>
                </p-button>

                <!-- apply filters -->
                <p-button [text]="true" (onClick)="applyFilters()">
                    <span>{{ 'apply' | translate }}</span>
                </p-button>
            </div>
        </cms-generic-filters>
    </div>
</cms-list>
import { CmsListComponent, GenericFiltersComponent } from '@x-angular/cms';

@Component({
  // ...,
  imports: [
    CmsListComponent,
    GenericFiltersComponent,
  ],
})
export class ProductsListComponent {
  @ViewChild(GenericFiltersComponent) filterComponent!: GenericFiltersComponent;
  constructor(public productsService: ProductService) {
    productsService.resetFilters$.subscribe(() => {
      this.resetFilters();
    });
  }

  // fetch data with new filters
  public applyFilters(): void {
    const filters = this.filterComponent.getFilters();
    this.productsService.queryParams$.next(filters);
  }

  // fetch data after reset filters
  public resetFilters(): void {
    this.filterComponent.resetFilters();
    this.productsService.queryParams$.next({});
  }
}

About

Manage repetitive CRUDs (Create, Read, Update and Delete) Operations with a few lines depending on PrimeNG

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published