| name | crud-page |
| description | Generate a complete CRUD or read-only list page in the Angular portal with service, routing, and menu integration. Use when adding a new feature page to the portal. |
Generate Portal CRUD Page
Generate a complete feature page for $ARGUMENTS in the OsmoX portal.
Step 1: Gather Requirements
Ask the user the following questions using AskUserQuestion. Batch related questions together (max 4 per call). Do NOT proceed until all answers are collected.
Batch 1
Q1 โ Page type:
- Full CRUD (list + create/edit dialog + delete confirmation) โ default
- Read-only list (list only, no create/edit/delete)
Q2 โ Access role:
- Any authenticated user โ no route guard
- ORG_ADMIN or higher โ
orgAdminGuard (default for config pages)
- SUPER_ADMIN only โ
superAdminGuard
Q3 โ Menu group:
- Home / Notifications / Configuration / Administration
- Also ask for a PrimeIcons icon name (e.g.,
pi-sitemap, pi-link, pi-users)
Q4 โ API integration:
- Types exist โ type alias already in
core/models/api.model.ts derived from generated OpenAPI types (ask which type name)
- Generate types โ run
npm run generate:api first, then add type alias to api.model.ts (see Step 3)
- No API yet โ stub service with
Record<string, unknown>, user wires up later
Batch 2
Q5 โ Entity fields (if "Create types" was selected, or for table/form generation):
Ask the user to describe fields. Use this format:
List fields as field_name: type. Annotate with:
* = primary key (shown in table, not editable in form)
+ = editable (shown in both table and create/edit form)
- No annotation = display-only (shown in table, not in form)
Example: *application_id: number, +name: string, +test_mode_enabled: boolean, status: number, created_on: string
Q6 โ Pagination:
- Client-side โ all data loaded at once,
p-table handles pagination internally (small datasets)
- Server-side โ
p-table with [lazy]="true", [totalRecords], and (onLazyLoad) event (large datasets). Sorting is also passed to the API via onLazyLoad.
Q7 โ Default sort:
- Which field should the table sort by initially? (e.g.,
created_on, name)
- Sort order: Descending (newest first, default) or Ascending
Q8 โ Table features (multi-select, all optional):
- CSV export โ adds an export button to the toolbar that calls
dt.exportCSV()
- Import โ adds file upload button + dialog for importing data (requires backend endpoint)
- None โ just the standard table
Step 2: Check API Capabilities
If an API endpoint exists for this feature, read the backend controller and/or Swagger docs to determine:
- What query params does the list endpoint support? (All v1 endpoints support
page, limit, sort, order)
- Are there any feature-specific filters?
- What fields are returned in the response DTO?
Use this info to configure the service and table correctly. All v1 APIs use PaginationQueryDto which supports:
page (number, default 1)
limit (number, default 20, max 1000)
sort (snake_case field name, e.g. created_on)
order (asc or desc, default desc)
Step 3: Read Reference Files
Before generating code, read the relevant canonical examples to match the exact patterns:
For Full CRUD pages, read:
apps/portal/src/app/features/super-admin/pages/organizations-list.ts (component pattern)
apps/portal/src/app/features/super-admin/pages/organizations-list.html (template pattern)
apps/portal/src/app/features/super-admin/services/organizations.service.ts (simple service)
For Read-only pages, read:
apps/portal/src/app/features/notifications/pages/notifications-list.ts
apps/portal/src/app/features/notifications/pages/notifications-list.html
For server-side pagination, also read:
apps/portal/src/app/features/applications/services/applications.service.ts (paginated service)
apps/portal/src/app/features/applications/pages/applications-list.ts (lazy-loading component pattern)
Always read:
apps/portal/src/app/app.routes.ts (routing structure)
apps/portal/src/app/layout/component/app.menu.ts (menu structure)
apps/portal/src/app/core/models/api.model.ts (existing entity interfaces)
Step 4: Scaffold and Generate Files
Use Angular CLI to scaffold files, then fill in the code adapting patterns from the reference files.
Scaffold with ng generate
Run from apps/portal/:
cd apps/portal
npx ng generate component features/<feature-name>/pages/<feature-name>-list --flat
npx ng generate service features/<feature-name>/services/<feature-name>
This creates the component (.ts, .html, .scss) and service (.ts) files with correct boilerplate. Then replace the generated content with the patterns below.
File structure
apps/portal/src/app/features/<feature-name>/
โโโ pages/
โ โโโ <feature-name>-list.ts # Component class
โ โโโ <feature-name>-list.html # Template
โ โโโ <feature-name>-list.scss # Styles (empty placeholder)
โโโ services/
โโโ <feature-name>.service.ts # API service
Naming conventions
| Concept | Format | Example (provider-chain-members) |
|---|
| Feature dir | features/<kebab-name>/ | features/provider-chain-members/ |
| Component class | <PascalName>ListComponent | ProviderChainMembersListComponent |
| Selector | app-<kebab-name>-list | app-provider-chain-members-list |
| Service class | <PascalName>Service | ProviderChainMembersService |
| Template | <kebab-name>-list.html | provider-chain-members-list.html |
Mandatory patterns (ALL components)
ChangeDetectionStrategy.OnPush โ always
inject() for DI โ NOT constructor injection
signal() / computed() for state โ NOT plain properties
@if / @for in templates โ NOT *ngIf / *ngFor
templateUrl + styleUrl โ NO inline template or styles
standalone: true is implicit in Angular 20 (omit from decorator)
viewChild<Table>('dt') for table reference
MessageService injected but NOT in component providers[] (it's global)
ConfirmationService in component providers[] (CRUD pages only)
- No raw DB ID columns โ generally avoid showing primary key IDs in tables. Resolve foreign key IDs to human-readable names using lookup methods. Exception: notification/archived-notification tables show the notification ID for debugging purposes
- Toolbar layout โ always include both
#start and #end templates in p-toolbar. For read-only pages with no action buttons, use an empty <ng-template #start></ng-template> to keep search/refresh right-aligned
Template structure
<div class="card">
<!-- Page header -->
<div> <h1> icon + title </h1> <p> subtitle </p> </div>
<!-- Toolbar -->
<p-toolbar>
#start: New button (CRUD only)
#end: export button (if enabled) + search input + refresh button
</p-toolbar>
<!-- Loading or data -->
@if (loading()) { <p-skeleton /> }
@else {
<p-table #dt [value]="items()" [paginator]="true" [rows]="10"
[stripedRows]="true" [rowHover]="true"
[sortField]="'default_field'" [sortOrder]="-1" ...>
#header: column headers with pSortableColumn
#body: data rows with action buttons
#emptymessage: "No items found"
</p-table>
}
<!-- Create/Edit dialog (CRUD only) -->
<p-dialog [visible]="dialogVisible()" ... />
<!-- Import dialog (if enabled) -->
<!-- Confirm delete dialog (CRUD only) -->
<p-confirmDialog />
</div>
Sorting on p-table
Always add pSortableColumn and p-sortIcon to sortable column headers. Set the default sort via:
<p-table [sortField]="'created_on'" [sortOrder]="-1" ...>
[sortOrder]="-1" = descending (newest first)
[sortOrder]="1" = ascending
For server-side lazy mode, sorting is handled via the onLazyLoad event โ see below.
Pagination on p-table
Client-side (all data loaded at once):
<p-table [value]="items()" [paginator]="true" [rows]="10"
[stripedRows]="true" [rowHover]="true"
[sortField]="'created_on'" [sortOrder]="-1"
[rowsPerPageOptions]="[10, 20, 50]" [showCurrentPageReport]="true"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} items">
Server-side (lazy loading โ data fetched per page, sort passed to API):
<p-table [value]="items()" [paginator]="true" [rows]="20"
[lazy]="true" [totalRecords]="totalRecords()" (onLazyLoad)="onLazyLoad($event)"
[stripedRows]="true" [rowHover]="true"
[sortField]="'created_on'" [sortOrder]="-1"
[rowsPerPageOptions]="[10, 20, 50]" [showCurrentPageReport]="true"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} items">
Component method for server-side:
import { TableLazyLoadEvent } from 'primeng/table';
readonly totalRecords = signal(0);
onLazyLoad(event: TableLazyLoadEvent): void {
const page = Math.floor((event.first ?? 0) / (event.rows ?? 20)) + 1;
const limit = event.rows ?? 20;
const sort = event.sortField as string | undefined;
const order = event.sortOrder === 1 ? 'asc' : 'desc';
this.loadItems(page, limit, sort, order);
}
CSV export (if enabled)
Add export button to toolbar #end:
<p-button icon="pi pi-download" severity="secondary" [text]="true"
[rounded]="true" pTooltip="Export CSV" (onClick)="dt.exportCSV()" />
No extra component logic needed โ dt is the template reference to p-table, and exportCSV() is built-in.
Import (if enabled)
Add import button to toolbar #start (next to "New" button):
<p-button label="Import" icon="pi pi-upload" severity="secondary" (onClick)="importDialogVisible.set(true)" />
Add import dialog with file upload:
<p-dialog [visible]="importDialogVisible()" (visibleChange)="importDialogVisible.set($event)"
header="Import Data" [modal]="true" [style]="{ width: '28rem' }">
<p-fileUpload mode="basic" accept=".csv,.json" [auto]="true"
(onUpload)="onImportUpload($event)" chooseLabel="Choose File" />
</p-dialog>
Component:
readonly importDialogVisible = signal(false);
onImportUpload(event: { files: File[] }): void {
this.importDialogVisible.set(false);
this.loadItems();
}
Service patterns
CRITICAL โ snake_case end-to-end for filter interfaces:
Filter/query-param interfaces in portal services MUST use snake_case property names that match the API query param names verbatim. Never use camelCase and translate at the HTTP boundary.
export interface MyFilters {
channel_type?: number;
date_from?: string;
application_id?: number;
}
if (filters?.channel_type) { params = params.set('channel_type', filters.channel_type); }
export interface MyFilters {
channelType?: number;
}
if (filters?.channelType) { params = params.set('channel_type', filters.channelType); }
This applies to all filter/request DTO interfaces defined inside services/*.service.ts files. Response entity types from core/models/api.model.ts (generated from OpenAPI) are already snake_case โ use them directly.
List with sort params (all v1 APIs support this):
list(page = 1, limit = 20, sort?: string, order?: string): Observable<PaginatedResponse<T>> {
let params = new HttpParams().set('page', page).set('limit', limit);
if (sort) { params = params.set('sort', sort); }
if (order) { params = params.set('order', order); }
return this.http.get<PaginatedResponse<T>>(this.apiUrl, { params });
}
DELETE always uses request body (not URL path param):
delete(id: number): Observable<boolean> {
return this.http.delete<boolean>(this.apiUrl, { body: { entity_id: id } });
}
Search options:
- Client-side โ via
[globalFilterFields] on p-table. Filters within currently-loaded page data. Good for small datasets.
- Server-side โ via
search query param on PaginationQueryDto. Searches across data, result, and createdBy fields on the backend. Use for large datasets or when searching JSONB fields. Debounce input with ~400ms timeout.
list(page = 1, limit = 20, filters?: { search?: string }): Observable<PaginatedResponse<T>> {
let params = new HttpParams().set('page', page).set('limit', limit);
if (filters?.search) { params = params.set('search', filters.search); }
return this.http.get<PaginatedResponse<T>>(this.apiUrl, { params });
}
Server-side filter dropdowns (for filterable columns like channel_type, delivery_status, application_id):
Add p-select dropdowns in toolbar #start:
<ng-template #start>
<div class="flex items-center gap-2 flex-wrap">
<p-select [options]="channelTypeOptions" [(ngModel)]="selectedChannelType"
placeholder="Channel type" [showClear]="true" (onChange)="onFilterChange()"
[style]="{ minWidth: '10rem' }" />
</div>
</ng-template>
Requires FormsModule and SelectModule in component imports. Each filter change resets to page 1 and reloads data with filter params.
JSON viewer dialog for large JSON columns (data, result, configuration):
Use the shared JsonViewerDialog component from shared/components/json-viewer-dialog/json-viewer-dialog. Add a "View" button in the table cell:
<td>
<p-button icon="pi pi-eye" [rounded]="true" [text]="true" severity="info"
pTooltip="View data" tooltipPosition="top"
(onClick)="viewJson(n.data, 'Data'); $event.stopPropagation()" />
</td>
Component needs jsonDialogVisible, jsonDialogData, jsonDialogHeader signals and a viewJson() method. Include <app-json-viewer-dialog> at the bottom of the template.
Step 5: Wire Up Routing and Menu
Add route to app.routes.ts
Add inside the AppLayout children array, in the appropriate section:
{
path: '<feature-name>',
canActivate: [<guard>],
loadComponent: () =>
import('./features/<feature-name>/pages/<feature-name>-list').then(
(m) => m.<PascalName>ListComponent,
),
},
Add menu item to app.menu.ts
Add to the appropriate group in the model computed signal. If role-gated, add conditionally:
if (isOrgAdmin) {
administrationItems.push({
label: '<Display Name>',
icon: 'pi pi-fw <icon>',
routerLink: ['/<feature-name>'],
});
}
Add type alias to api.model.ts (if generating types)
After running npm run generate:api, add a type alias in core/models/api.model.ts:
import { components } from '../types/api.types';
export type MyEntity = components['schemas']['MyEntityResponseDto'];
export type CreateMyEntityInput = components['schemas']['CreateMyEntityInput'];
export type UpdateMyEntityInput = components['schemas']['UpdateMyEntityInput'];
NEVER create manual interfaces for API entities โ always derive from the generated types.
snake_case for filter / query-param interfaces (MANDATORY)
When the generated service contains a Filters interface for the list endpoint's query params (e.g. MyEntityFilters with channel_type, application_id, date_from), every field must be snake_case โ exactly matching the backend query-param names. This is the same rule that applies to API response entities, applied to request shapes.
export interface MyEntityFilters {
channel_type?: number;
application_id?: number;
date_from?: string;
status?: number;
}
if (filters?.channel_type) {
params = params.set('channel_type', filters.channel_type);
}
Why: filter interfaces are part of the API contract just like response DTOs. Keeping filters.field_name โ params.set('field_name', ...) โ ?field_name=... aligned end-to-end means renames stay consistent and there's no place for typo-driven silent drops. Generic TypeScript camelCase advice does not apply here โ see apps/portal/CLAUDE.md ยง "snake_case applies to request shapes too".
Step 6: Verify
- Run
cd apps/portal && npx ng build โ must succeed with zero errors
- Run
cd apps/portal && npm run lint โ must pass
- Navigate to the page in the browser and verify it loads