@x-angular/cms
v1.2.2
Published
Manage repetitive CRUDs (Create, Read, Update and Delete) Operations with a few lines depending on PrimeNG
Downloads
4
Readme
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:
- Generic Filters Builder
- Generic Form Builder
- Manage State
- Caching
- Display items with table or with custom view
- 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:
- 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;
}
- Declare @Injecable Service
ProductService
which extendsCmsService
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
- 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:
- 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
},
// ...,
],
},
};
}
- 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:
- Set the column
clickable
as true in yourtableConfiguration
:
export class ProductService extends CmsService<Product> {
public override crudConfiguration: CRUDConfiguration<Product> = {
tableConfiguration: {
// ...,
columns: [
{
title: "image",
key: "thumbnail",
templateRef: true,
clickable: true, // <-- add here
},
// ...,
],
},
};
}
- 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.
- 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),
},
]
},
];
- Navigate to Specific route (optional):
default route is
view/:id
but if you want to change this value you can overrideviewDetailsRoute
:
@Injectable()
export class ProductService extends CmsService<Product> {
override viewDetailsRoute = (item: Product) => `view-details/${item.id}`;
}
- 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
},
}
}
- 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
- dialog
- 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
- password
- number
- date
- checkbox
- triStateCheckbox
- radio
- time
- color
- dropdown
- autocomplete
- multiSelect
Setup open form type with page
:
- Add your create/update routes, default: ("new", "update/:id")
- 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
- 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({});
}
}