@team-codeme/crud-forge 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -347,6 +347,76 @@ function buildScreenAccessCatalog({ entities = [], folderName = 'micro-functions
347
347
  return { csv: `${csv}\n`, screens, rows };
348
348
  }
349
349
 
350
+ function parseCsvLine(line) {
351
+ const cells = [];
352
+ let current = '';
353
+ let inQuotes = false;
354
+ for (let i = 0; i < line.length; i += 1) {
355
+ const ch = line[i];
356
+ if (inQuotes) {
357
+ if (ch === '"' && line[i + 1] === '"') {
358
+ current += '"';
359
+ i += 1;
360
+ } else if (ch === '"') {
361
+ inQuotes = false;
362
+ } else {
363
+ current += ch;
364
+ }
365
+ continue;
366
+ }
367
+ if (ch === '"') {
368
+ inQuotes = true;
369
+ continue;
370
+ }
371
+ if (ch === ',') {
372
+ cells.push(current);
373
+ current = '';
374
+ continue;
375
+ }
376
+ current += ch;
377
+ }
378
+ cells.push(current);
379
+ return cells;
380
+ }
381
+
382
+ function parseScreenComponentsCsv(text) {
383
+ const lines = String(text || '')
384
+ .split(/\r?\n/)
385
+ .map((line) => line.trim())
386
+ .filter(Boolean);
387
+ if (lines.length < 2) return [];
388
+ const rows = [];
389
+ for (let i = 1; i < lines.length; i += 1) {
390
+ const cells = parseCsvLine(lines[i]);
391
+ if (cells.length < 5) continue;
392
+ rows.push({
393
+ screenName: cells[0] || '',
394
+ screenId: cells[1] || '',
395
+ componentName: cells[2] || '',
396
+ componentId: cells[3] || '',
397
+ componentType: cells[4] || '',
398
+ description: cells[5] || '',
399
+ });
400
+ }
401
+ return rows;
402
+ }
403
+
404
+ function screensFromCsvRows(rows) {
405
+ const seen = new Set();
406
+ const screens = [];
407
+ for (const row of rows) {
408
+ const type = String(row.componentType || '').toUpperCase();
409
+ const isScreen = type === 'SCREEN' || row.componentId === row.screenId;
410
+ if (!isScreen || !row.screenId || seen.has(row.screenId)) continue;
411
+ seen.add(row.screenId);
412
+ screens.push({
413
+ id: row.screenId,
414
+ label: row.screenName || row.screenId.split('/').pop()?.replace(/-/g, ' ') || row.screenId,
415
+ });
416
+ }
417
+ return screens;
418
+ }
419
+
350
420
  async function generateIamModule({ outputDir, templatesDir, locals, writeOrPreview, folderName, config, entities = [] }) {
351
421
  const iamRoot = path.join(outputDir, 'src/app/core/user-management');
352
422
  const catalog = buildScreenAccessCatalog({
@@ -354,10 +424,40 @@ async function generateIamModule({ outputDir, templatesDir, locals, writeOrPrevi
354
424
  folderName: folderName || 'micro-functions',
355
425
  iam: locals.iam || {},
356
426
  });
357
- locals = { ...locals, managedScreens: catalog.screens };
427
+
428
+ const csvPath = path.join(outputDir, 'public/assets/screen-components.csv');
429
+ let finalRows = catalog.rows;
430
+ let finalScreens = catalog.screens;
431
+
432
+ if (fs.existsSync(csvPath)) {
433
+ try {
434
+ const existingCsvText = fs.readFileSync(csvPath, 'utf8');
435
+ const existingRows = parseScreenComponentsCsv(existingCsvText);
436
+ const newScreenIds = new Set(catalog.screens.map((s) => s.id));
437
+ const preservedRows = existingRows.filter((r) => !newScreenIds.has(r.screenId));
438
+ if (preservedRows.length > 0) {
439
+ finalRows = [...preservedRows, ...catalog.rows];
440
+ finalScreens = screensFromCsvRows(finalRows);
441
+ }
442
+ } catch {
443
+ /* ignore and use fresh catalog */
444
+ }
445
+ }
446
+
447
+ const header = 'screenName,screenId,componentName,componentId,componentType,description';
448
+ const mergedCsv = [
449
+ header,
450
+ ...finalRows.map((row) =>
451
+ [row.screenName, row.screenId, row.componentName, row.componentId, row.componentType, row.description]
452
+ .map(csvEscape)
453
+ .join(',')
454
+ ),
455
+ ].join('\n') + '\n';
456
+
457
+ locals = { ...locals, managedScreens: finalScreens };
358
458
 
359
459
  // PMB-style ACL registry — matrices load this CSV at runtime.
360
- writeOrPreview(path.join(outputDir, 'public/assets/screen-components.csv'), catalog.csv);
460
+ writeOrPreview(csvPath, mergedCsv, { bypassMerge: true });
361
461
 
362
462
  /** Flat template name → foldered output under src/app/core/user-management */
363
463
  const iamLayout = [
@@ -531,7 +631,11 @@ async function generateIamModule({ outputDir, templatesDir, locals, writeOrPrevi
531
631
 
532
632
  for (const entry of iamLayout) {
533
633
  const rendered = await renderTemplate(path.join(templatesDir, 'iam', `${entry.template}.ejs`), locals);
534
- writeOrPreview(path.join(iamRoot, entry.out), rendered);
634
+ writeOrPreview(
635
+ path.join(iamRoot, entry.out),
636
+ rendered,
637
+ entry.template === 'screen-catalog.ts' ? { bypassMerge: true } : {}
638
+ );
535
639
  generatedFiles += 1;
536
640
  if (entry.html) {
537
641
  const htmlRendered = await renderTemplate(path.join(templatesDir, 'iam', `${entry.html}.ejs`), locals);
@@ -668,7 +772,7 @@ function registerIamRoutes(outputDir, config, folderName, writeOrPreview) {
668
772
  legacyImport,
669
773
  "loadChildren: () => import('@/app/core/user-management/user-management.routes')"
670
774
  );
671
- writeOrPreview(routesFilePath, content);
775
+ writeOrPreview(routesFilePath, content, { bypassMerge: true });
672
776
  return;
673
777
  }
674
778
 
@@ -680,7 +784,7 @@ function registerIamRoutes(outputDir, config, folderName, writeOrPreview) {
680
784
  } else {
681
785
  content = content.replace('children: [', `children: [\n${iamRoute}`);
682
786
  }
683
- writeOrPreview(routesFilePath, content);
787
+ writeOrPreview(routesFilePath, content, { bypassMerge: true });
684
788
  }
685
789
 
686
790
  function registerIamNavigation(outputDir, config, folderName, writeOrPreview) {
@@ -705,7 +809,7 @@ function registerIamNavigation(outputDir, config, folderName, writeOrPreview) {
705
809
  /id: 'identity-group',\n\s*label: '[^']*',(?:\n\s*description: '[^']*',)?\n\s*icon: '[^']*'/,
706
810
  `id: 'identity-group',\n label: 'User Management',\n icon: 'users'`
707
811
  );
708
- writeOrPreview(navFilePath, content);
812
+ writeOrPreview(navFilePath, content, { bypassMerge: true });
709
813
  return;
710
814
  }
711
815
 
@@ -717,7 +821,7 @@ function registerIamNavigation(outputDir, config, folderName, writeOrPreview) {
717
821
  `export const NAVIGATION: NavigationItem[] = [\n${block}`
718
822
  );
719
823
  }
720
- writeOrPreview(navFilePath, content);
824
+ writeOrPreview(navFilePath, content, { bypassMerge: true });
721
825
  }
722
826
 
723
827
  function patchAppConfig(outputDir, writeOrPreview) {
@@ -2,14 +2,18 @@ const path = require('path');
2
2
  const { kebabCase } = require('lodash');
3
3
 
4
4
  // Source folders name service modules; the common container is always micro-functions.
5
- function serviceFolder(entity, config) {
6
- const multiSource = Array.isArray(config.input?.sources) && config.input.sources.length > 0;
7
- const module = multiSource
8
- ? entity.folderName || kebabCase(entity.sourceName || entity.microservice)
9
- : kebabCase(entity.microservice || config.api?.microservice || 'microservice');
5
+ function serviceFolder(entity, config = {}) {
6
+ const multiSource = Array.isArray(config?.input?.sources) && config.input.sources.length > 0;
7
+ const folder = entity.folderName && entity.folderName !== 'micro-functions' ? entity.folderName : null;
8
+ let module = multiSource
9
+ ? (folder || kebabCase(entity.sourceName || entity.microservice || 'microservice'))
10
+ : kebabCase(folder || entity.microservice || config?.api?.microservice || 'microservice');
10
11
  if (!module || /[\\/]/.test(module) || ['.', '..'].includes(module)) {
11
12
  throw new Error('Microservice module must be a single folder name.');
12
13
  }
14
+ if (module === 'micro-functions') {
15
+ return 'micro-functions';
16
+ }
13
17
  return `micro-functions/${module}`;
14
18
  }
15
19
 
@@ -77,7 +77,7 @@ async function generate(parsedData, config) {
77
77
  }
78
78
  return true;
79
79
  };
80
- fs.copySync(starterPackPath, outputDir, { filter: filterFn });
80
+ fs.copySync(starterPackPath, outputDir, { filter: filterFn, overwrite: false });
81
81
  cleanupStaleKeycloakArtifacts(outputDir, config);
82
82
  }
83
83
  }
@@ -95,7 +95,7 @@ async function generate(parsedData, config) {
95
95
  const conflicts = [];
96
96
  const { stampGeneratedBanner } = require('./generated-banner');
97
97
 
98
- const writeOrPreview = (filePath, rawContent) => {
98
+ const writeOrPreview = (filePath, rawContent, writeOpts = {}) => {
99
99
  const content = stampGeneratedBanner(filePath, rawContent);
100
100
  const relativePath = path.relative(outputDir, filePath);
101
101
  const exists = fs.existsSync(filePath);
@@ -138,6 +138,12 @@ async function generate(parsedData, config) {
138
138
  }
139
139
 
140
140
  if (policy === 'merge') {
141
+ if (writeOpts.bypassMerge) {
142
+ fs.ensureDirSync(path.dirname(filePath));
143
+ fs.writeFileSync(filePath, content);
144
+ fileCount++;
145
+ return;
146
+ }
141
147
  const { mergeContent } = require('../utils/merger');
142
148
  const merged = mergeContent(existingContent, content);
143
149
  if (merged !== existingContent) {
@@ -451,23 +457,50 @@ async function generate(parsedData, config) {
451
457
  content = content.replace(/\s*\/\/\s*-+\n\s*\/\/\s*(Dashboards|General|Documentation)\n\s*\/\/\s*-+\n(?:[\s\S]*?(?=\s*\/\/\s*-+|\s*\/\/\s*404|\]))/g, '\n');
452
458
  }
453
459
 
454
- const routesBlock = entities.map(entity => {
460
+ // Merge with existing routes between // cforge-routes-start and // cforge-routes-end
461
+ const existingRoutesMap = new Map();
462
+ const cforgeRoutesMatch = content.match(/\/\/ cforge-routes-start\s*([\s\S]*?)\/\/ cforge-routes-end/);
463
+ if (cforgeRoutesMatch && cforgeRoutesMatch[1]) {
464
+ const routeRegex = /\{\s*path:\s*['"]([^'"]+)['"][\s\S]*?\n\s*\},?/g;
465
+ let m;
466
+ while ((m = routeRegex.exec(cforgeRoutesMatch[1])) !== null) {
467
+ const p = m[1];
468
+ if (p && p !== 'user-management') {
469
+ existingRoutesMap.set(p, m[0].trim().replace(/,$/, ''));
470
+ }
471
+ }
472
+ }
473
+
474
+ for (const entity of entities) {
455
475
  const kebabName = helpers.toKebabCase(entity.name);
456
- return ` {\n path: '${kebabName}',\n loadChildren: () => import('./modules/apps/${outputEntityFolder(entity, config)}/${kebabName}/${kebabName}.routes'),\n },`;
457
- }).join('\n');
476
+ const routeStr = `{\n path: '${kebabName}',\n loadChildren: () => import('./modules/apps/${outputEntityFolder(entity, config)}/${kebabName}/${kebabName}.routes'),\n }`;
477
+ existingRoutesMap.set(kebabName, routeStr);
478
+ }
479
+
480
+ const mergedRoutes = Array.from(existingRoutesMap.values())
481
+ .map(r => ` ${r},`)
482
+ .join('\n');
458
483
 
459
484
  if (config.fuse.cleanLayout) {
460
485
  let content = fs.readFileSync(routesFilePath, 'utf8').replace(/\r\n/g, '\n');
461
486
  const childrenMatch = /children:\s*\[/.exec(content);
462
487
  if (childrenMatch) {
463
- const cleanChildrenBlock = `children: [
488
+ // Preserve IAM user-management route added by auth-pack; cleanLayout rewrites children.
489
+ const iamRouteMatch = content.match(
490
+ /\{\s*path:\s*['"]user-management['"][\s\S]*?loadChildren:\s*\(\)\s*=>\s*import\(['"][^'"]+user-management\.routes['"]\)\s*,?\s*\},?/,
491
+ );
492
+ const iamRouteBlock = iamRouteMatch
493
+ ? ` ${iamRouteMatch[0].replace(/,\s*$/, '')},\n`
494
+ : '';
495
+
496
+ const cleanChildrenBlock = `children: [
464
497
  { path: '', pathMatch: 'full', redirectTo: '/auth/sign-in' },
465
498
  {
466
499
  path: 'welcome',
467
500
  loadComponent: () => import('./modules/extras/welcome/welcome.component')
468
501
  },
469
- // cforge-routes-start
470
- ${routesBlock}
502
+ ${iamRouteBlock} // cforge-routes-start
503
+ ${mergedRoutes}
471
504
  // cforge-routes-end
472
505
  {
473
506
  path: '404',
@@ -478,16 +511,29 @@ ${routesBlock}
478
511
  {
479
512
  path: '**',
480
513
  loadComponent: () => import('./modules/extras/error/features/error-page'),
481
- data: { errorCode: 404, title: 'Page not found' }
514
+ data: { errorCode: 404, title: 'Page not found' }
482
515
  },
483
516
  ]`;
484
- const routesArrayEnd = content.indexOf(' ],\n },\n];');
485
- if (routesArrayEnd !== -1) {
486
- const newContent = content.slice(0, childrenMatch.index) + cleanChildrenBlock + content.slice(routesArrayEnd + 6);
487
- writeOrPreview(routesFilePath, newContent);
517
+ let depth = 1;
518
+ let childrenEndIdx = -1;
519
+ const searchStart = childrenMatch.index + childrenMatch[0].length;
520
+ for (let i = searchStart; i < content.length; i++) {
521
+ if (content[i] === '[') depth++;
522
+ else if (content[i] === ']') {
523
+ depth--;
524
+ if (depth === 0) {
525
+ childrenEndIdx = i;
526
+ break;
527
+ }
528
+ }
529
+ }
530
+ if (childrenEndIdx !== -1) {
531
+ const newContent = content.slice(0, childrenMatch.index) + cleanChildrenBlock + content.slice(childrenEndIdx + 1);
532
+ writeOrPreview(routesFilePath, newContent, { bypassMerge: true });
488
533
  }
489
534
  }
490
535
  } else {
536
+ let content = fs.readFileSync(routesFilePath, 'utf8').replace(/\r\n/g, '\n');
491
537
  // Ensure markers exist
492
538
  if (!content.includes('// cforge-routes-start') || !content.includes('// cforge-routes-end')) {
493
539
  const childrenMatch = /children:\s*\[/.exec(content);
@@ -502,23 +548,55 @@ ${routesBlock}
502
548
 
503
549
  if (startIdx !== -1 && endIdx !== -1) {
504
550
  const startMarker = '// cforge-routes-start';
505
- const endMarker = '// cforge-routes-end';
506
- const newContent = content.slice(0, startIdx + startMarker.length) + '\n' + routesBlock + '\n ' + content.slice(endIdx);
507
- writeOrPreview(routesFilePath, newContent);
551
+ const newContent = content.slice(0, startIdx + startMarker.length) + '\n' + mergedRoutes + '\n ' + content.slice(endIdx);
552
+ writeOrPreview(routesFilePath, newContent, { bypassMerge: true });
508
553
  }
509
554
  }
510
555
  }
511
556
 
512
557
  // 2. Update Navigation File
513
558
  if (config.generate.registrations?.navigation !== false && fs.existsSync(navFilePath)) {
514
-
515
-
516
559
  let content = fs.readFileSync(navFilePath, 'utf8');
517
560
 
518
561
  if (config.fuse?.includePrebuilt === false && !config.fuse?.cleanLayout) {
519
562
  content = content.replace(/\s*{\s*id:\s*'(dashboards|general|extras|documentation)'[\s\S]*?(?=\s*{\s*id:\s*'(dashboards|general|extras|documentation)'|\s*\];)/g, '');
520
563
  }
521
564
 
565
+ // Extract existing navigation groups from NAVIGATION array
566
+ const existingNavGroupsMap = new Map();
567
+ const navArrayStart = content.indexOf('export const NAVIGATION: NavigationItem[] = [');
568
+ if (navArrayStart !== -1) {
569
+ const afterStart = content.slice(navArrayStart + 'export const NAVIGATION: NavigationItem[] = ['.length);
570
+ const navArrayEnd = afterStart.lastIndexOf('];');
571
+ const arrayBody = navArrayEnd !== -1 ? afterStart.slice(0, navArrayEnd) : afterStart;
572
+
573
+ let depth = 0;
574
+ let currentGroupStart = -1;
575
+ for (let i = 0; i < arrayBody.length; i++) {
576
+ const ch = arrayBody[i];
577
+ if (ch === '{') {
578
+ if (depth === 0) currentGroupStart = i;
579
+ depth++;
580
+ } else if (ch === '}') {
581
+ depth--;
582
+ if (depth === 0 && currentGroupStart !== -1) {
583
+ const rawGroup = arrayBody.slice(currentGroupStart, i + 1);
584
+ const idMatch = rawGroup.match(/id:\s*['"]([^'"]+)['"]/);
585
+ if (idMatch) {
586
+ const groupId = idMatch[1];
587
+ if (
588
+ !config.fuse.cleanLayout ||
589
+ !['dashboards', 'general', 'extras', 'documentation'].includes(groupId)
590
+ ) {
591
+ existingNavGroupsMap.set(groupId, rawGroup.trim().replace(/,$/, ''));
592
+ }
593
+ }
594
+ currentGroupStart = -1;
595
+ }
596
+ }
597
+ }
598
+ }
599
+
522
600
  const groups = new Map();
523
601
  for (const entity of entities) {
524
602
  const entityFolder = entity.folderName || folderName;
@@ -547,28 +625,31 @@ ${routesBlock}
547
625
  });
548
626
  }
549
627
 
550
- const navigationBlock = Array.from(groups.values())
551
- .map((group) => {
552
- group.items.sort((a, b) => {
553
- if (a.order !== b.order) return a.order - b.order;
554
- return a.label.localeCompare(b.label);
555
- });
556
- const formattedItems = group.items
557
- .map((item) => {
558
- let str = ` {\n`;
559
- str += ` id: '${item.id}',\n`;
560
- str += ` label: '${item.label}',\n`;
561
- str += ` icon: '${item.icon}',\n`;
562
- str += ` route: '${item.route}'`;
563
- if (item.permissions) {
564
- str += `,\n meta: { permissions: ${JSON.stringify(item.permissions)} }`;
565
- }
566
- str += `\n }`;
567
- return str;
568
- })
569
- .join(',\n');
570
- return ` {\n id: '${group.folder}-group',\n label: '${group.label}',\n description: 'Generated CRUD Modules',\n icon: 'database',\n children: [\n${formattedItems}\n ]\n },`;
571
- })
628
+ for (const group of groups.values()) {
629
+ group.items.sort((a, b) => {
630
+ if (a.order !== b.order) return a.order - b.order;
631
+ return a.label.localeCompare(b.label);
632
+ });
633
+ const formattedItems = group.items
634
+ .map((item) => {
635
+ let str = ` {\n`;
636
+ str += ` id: '${item.id}',\n`;
637
+ str += ` label: '${item.label}',\n`;
638
+ str += ` icon: '${item.icon}',\n`;
639
+ str += ` route: '${item.route}'`;
640
+ if (item.permissions) {
641
+ str += `,\n meta: { permissions: ${JSON.stringify(item.permissions)} }`;
642
+ }
643
+ str += `\n }`;
644
+ return str;
645
+ })
646
+ .join(',\n');
647
+ const groupBlock = ` {\n id: '${group.folder}-group',\n label: '${group.label}',\n description: 'Generated CRUD Modules',\n icon: 'database',\n children: [\n${formattedItems}\n ]\n }`;
648
+ existingNavGroupsMap.set(`${group.folder}-group`, groupBlock.trim());
649
+ }
650
+
651
+ const mergedNavigationBlock = Array.from(existingNavGroupsMap.values())
652
+ .map((g) => (g.startsWith(' ') ? `${g},` : ` ${g},`))
572
653
  .join('\n');
573
654
 
574
655
  if (config.fuse.cleanLayout) {
@@ -577,8 +658,8 @@ ${routesBlock}
577
658
  if (startArrayIdx !== -1) {
578
659
  const arrayEndIdx = normalized.lastIndexOf('];');
579
660
  if (arrayEndIdx !== -1) {
580
- const newContent = content.slice(0, startArrayIdx) + 'export const NAVIGATION: NavigationItem[] = [\n' + navigationBlock + '\n];' + content.slice(arrayEndIdx + 2);
581
- writeOrPreview(navFilePath, newContent);
661
+ const newContent = content.slice(0, startArrayIdx) + 'export const NAVIGATION: NavigationItem[] = [\n' + mergedNavigationBlock + '\n];' + content.slice(arrayEndIdx + 2);
662
+ writeOrPreview(navFilePath, newContent, { bypassMerge: true });
582
663
  }
583
664
  }
584
665
  } else {
@@ -596,9 +677,8 @@ ${routesBlock}
596
677
 
597
678
  if (startIdx !== -1 && endIdx !== -1) {
598
679
  const startMarker = '// cforge-navigation-start';
599
- const endMarker = '// cforge-navigation-end';
600
- const newContent = content.slice(0, startIdx + startMarker.length) + '\n' + navigationBlock + '\n ' + content.slice(endIdx);
601
- writeOrPreview(navFilePath, newContent);
680
+ const newContent = content.slice(0, startIdx + startMarker.length) + '\n' + mergedNavigationBlock + '\n ' + content.slice(endIdx);
681
+ writeOrPreview(navFilePath, newContent, { bypassMerge: true });
602
682
  }
603
683
  }
604
684
  }
@@ -104,8 +104,9 @@ async function generateProject(parsed, config, { configPath, inputPath, options
104
104
  action = answer.action;
105
105
  }
106
106
  if (action === 'skip') { change.skip = true; continue; }
107
- // Merge cannot patch binaries (logos, sounds, fonts) — keep the existing file.
108
- if (action === 'merge' && binary) { change.skip = true; continue; }
107
+ if (action === 'merge' && binary) {
108
+ throw new Error(`Cannot merge binary file "${change.target}". Use skip, ask, or overwrite. No project files were changed.`);
109
+ }
109
110
  if (action === 'merge') change.content = Buffer.from(mergeContent(change.old.toString('utf8'), change.content.toString('utf8')));
110
111
  }
111
112
  if (!config.dryRun) {
@@ -16,7 +16,7 @@ import { AccessCtrlPipe } from '@/app/core/auth';
16
16
  selector: 'app-<%= toKebabCase(entity.name) %>-actions',
17
17
  templateUrl: './<%= toKebabCase(entity.name) %>-actions.component.html',
18
18
  host: {
19
- class: 'block w-full xl:w-auto',
19
+ class: 'block w-full sm:w-auto md:shrink-0',
20
20
  },
21
21
  standalone: true,
22
22
  imports: [
@@ -50,7 +50,8 @@ export class <%= entity.name %>ActionsComponent {
50
50
  @Output() newClick = new EventEmitter<void>();
51
51
 
52
52
  onSearchByChange(value: string): void {
53
- this.searchChange.emit({ searchBy: value, searchTerm: this.searchTerm });
53
+ this.searchTerm = '';
54
+ this.searchChange.emit({ searchBy: value, searchTerm: '' });
54
55
  }
55
56
 
56
57
  onSearchTermChange(value: string): void {
@@ -31,9 +31,9 @@ const estimateHeaderWidth = (label) => {
31
31
  <div #listViewport class="absolute inset-0 flex min-w-0 flex-col overflow-hidden">
32
32
 
33
33
  <!-- Header (fixed) -->
34
- <div class="flex flex-0 flex-col border-b border-slate-200 bg-slate-100 p-6 pb-22 md:flex-row md:items-center md:justify-between md:px-10 md:pb-28 md:pt-12 dark:border-slate-800 dark:bg-slate-900/60">
35
- <div class="min-w-0 flex-1">
36
- <h2 class="truncate text-3xl font-extrabold leading-7 tracking-tight sm:leading-10 md:text-4xl">
34
+ <div class="flex flex-0 flex-col border-b border-slate-200 bg-slate-100 p-6 pb-20 sm:flex-row sm:items-center sm:justify-between sm:flex-wrap md:px-10 md:pb-28 md:pt-12 dark:border-slate-800 dark:bg-slate-900/60">
35
+ <div class="min-w-0 flex-1 shrink-0">
36
+ <h2 class="text-3xl font-extrabold leading-7 tracking-tight sm:leading-10 md:text-4xl">
37
37
  <%= entity.name.replace(/([A-Z])/g, ' $1').trim() %> Management
38
38
  </h2>
39
39
  </div>
@@ -220,12 +220,6 @@ export class <%= entity.name %>ListComponent extends BaseListComponent<<%= prefe
220
220
  this.restoreViewMode();
221
221
  this.restoreColumnPreferences();
222
222
  this.loadSearchState();
223
- if (typeof localStorage !== 'undefined') {
224
- const savedPageSize = localStorage.getItem(this.storageKeyPageSize);
225
- if (savedPageSize) {
226
- this.itemsPerPage = Number(savedPageSize);
227
- }
228
- }
229
223
  if (this.dialogData?.parentFilters) {
230
224
  this.baseParentFilters = { ...this.dialogData.parentFilters };
231
225
  }
@@ -287,13 +281,13 @@ export class <%= entity.name %>ListComponent extends BaseListComponent<<%= prefe
287
281
  return;
288
282
  }
289
283
 
290
- if (typeof localStorage !== 'undefined' && this.paginator.pageSize !== this.itemsPerPage) {
284
+ if (this.paginator && this.paginator.pageSize !== this.itemsPerPage) {
291
285
  this.itemsPerPage = this.paginator.pageSize;
292
- localStorage.setItem(this.storageKeyPageSize, String(this.itemsPerPage));
293
286
  }
294
287
 
295
288
  this.listRequest?.unsubscribe();
296
289
  this.isLoading = true;
290
+ this.cdr.markForCheck();
297
291
  const req = {
298
292
  page: this.paginator.pageIndex,
299
293
  size: this.paginator.pageSize,
@@ -307,12 +301,15 @@ export class <%= entity.name %>ListComponent extends BaseListComponent<<%= prefe
307
301
  tap(res => {
308
302
  this.isLoading = false;
309
303
  this.totalItems = Number(res.headers.get('X-Total-Count') ?? 0);
310
- this.dataSource.data = this.sortRowsForDisplay(res.body ?? []);
304
+ this.rawRows = res.body ?? [];
305
+ this.dataSource.data = this.sortRowsForDisplay(this.rawRows);
306
+ this.cdr.markForCheck();
311
307
  this.cdr.detectChanges();
312
308
  setTimeout(() => { if (!this.destroyRef.destroyed) this.onTableScroll(); }, 0);
313
309
  }),
314
310
  catchError(() => {
315
311
  this.isLoading = false;
312
+ this.cdr.markForCheck();
316
313
  this.cdr.detectChanges();
317
314
  return of(null);
318
315
  })
@@ -528,8 +525,13 @@ export class <%= entity.name %>ListComponent extends BaseListComponent<<%= prefe
528
525
  <% }); %>
529
526
 
530
527
  handleSearchChanged(event: { searchBy: string; searchTerm: string }): void {
531
- this.searchBy = event.searchBy;
532
- this.searchTerm = event.searchTerm;
528
+ if (event.searchBy !== this.searchBy) {
529
+ this.searchBy = event.searchBy;
530
+ this.searchTerm = '';
531
+ } else {
532
+ this.searchBy = event.searchBy;
533
+ this.searchTerm = event.searchTerm;
534
+ }
533
535
 
534
536
  if (typeof localStorage !== 'undefined') {
535
537
  localStorage.setItem(this.storageKeySearchTerm, this.searchTerm);
@@ -3,16 +3,16 @@
3
3
  <div class="absolute inset-0 flex min-w-0 flex-col overflow-hidden">
4
4
 
5
5
  <!-- Header (fixed, does not scroll) — PMB User Management layout -->
6
- <div class="flex flex-0 flex-col border-b border-slate-200 bg-slate-100 p-6 pb-22 md:flex-row md:items-center md:justify-between md:px-10 md:pb-28 md:pt-12 dark:border-slate-800 dark:bg-slate-900/60">
7
- <div class="min-w-0 flex-1">
6
+ <div class="flex flex-0 flex-col border-b border-slate-200 bg-slate-100 p-6 pb-20 sm:flex-row sm:items-center sm:justify-between sm:flex-wrap md:px-10 md:pb-28 md:pt-12 dark:border-slate-800 dark:bg-slate-900/60">
7
+ <div class="min-w-0 flex-1 shrink-0">
8
8
  <div class="flex flex-wrap items-center gap-3">
9
- <h2 class="truncate text-3xl font-extrabold leading-7 tracking-tight sm:leading-10 md:text-4xl">
9
+ <h2 class="text-3xl font-extrabold leading-7 tracking-tight sm:leading-10 md:text-4xl">
10
10
  User Management <span class="text-2xl font-bold text-primary-800 tabular-nums dark:text-primary-400 md:text-3xl">({{ totalItems }})</span>
11
11
  </h2>
12
12
  </div>
13
13
  </div>
14
14
 
15
- <div class="mt-6 grid w-full grid-cols-2 gap-3 md:ml-4 md:mt-0 md:flex md:w-auto md:shrink-0 md:items-center">
15
+ <div class="mt-6 grid w-full grid-cols-2 gap-3 sm:mt-0 sm:w-auto sm:flex sm:shrink-0 sm:items-center md:ml-4">
16
16
  <mat-form-field appearance="outline" subscriptSizing="dynamic" class="col-span-2 w-full md:w-64">
17
17
  <mat-icon matPrefix svgIcon="search" class="mr-2 text-neutral-500"></mat-icon>
18
18
  <input matInput placeholder="Search users..." [formControl]="searchControl" autocomplete="off" />
@@ -1,6 +1,6 @@
1
1
  import { CdkScrollable } from '@angular/cdk/scrolling';
2
2
  import { CommonModule } from '@angular/common';
3
- import { Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
3
+ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
4
4
  import { FormControl, ReactiveFormsModule } from '@angular/forms';
5
5
  import { MatButtonModule } from '@angular/material/button';
6
6
  import { MatDialog, MatDialogModule } from '@angular/material/dialog';
@@ -50,14 +50,16 @@ type ColumnPref = { key: string; label: string; alwaysVisible?: boolean };
50
50
  UserDetailComponent,
51
51
  UserFormComponent,
52
52
  ],
53
- host: { class: 'relative flex h-full min-h-0 w-full min-w-0 flex-auto flex-col' },
53
+ host: { class: 'cforge-iam-list relative flex h-full min-h-0 w-full min-w-0 flex-auto flex-col' },
54
54
  templateUrl: './user-list.component.html',
55
55
  styleUrl: './user-list.component.scss',
56
+ changeDetection: ChangeDetectionStrategy.OnPush,
56
57
  })
57
58
  export class UserListComponent implements OnInit, OnDestroy {
58
59
  readonly SCREEN = 'user-management';
59
60
  @ViewChild('drawer') drawer?: MatSidenav;
60
61
 
62
+ private readonly cdr = inject(ChangeDetectorRef);
61
63
  private readonly keycloak = inject(KeycloakAdminService);
62
64
  private readonly dialog = inject(MatDialog);
63
65
  private readonly router = inject(Router);
@@ -135,6 +137,7 @@ export class UserListComponent implements OnInit, OnDestroy {
135
137
 
136
138
  onSortChange(sort: Sort): void {
137
139
  this.sortState = sort;
140
+ this.cdr.markForCheck();
138
141
  }
139
142
 
140
143
  setViewMode(mode: 'table' | 'card'): void {
@@ -144,21 +147,25 @@ export class UserListComponent implements OnInit, OnDestroy {
144
147
  } catch {
145
148
  /* ignore */
146
149
  }
150
+ this.cdr.markForCheck();
147
151
  }
148
152
 
149
153
  load(): void {
150
154
  this.isSearching = true;
155
+ this.cdr.markForCheck();
151
156
  const term = (this.searchControl.value || '').trim();
152
157
  this.keycloak.searchUsers(term, this.pageIndex, this.pageSize).subscribe({
153
158
  next: (page) => {
154
159
  this.users = page.users;
155
160
  this.totalItems = page.totalCount;
156
161
  this.isSearching = false;
162
+ this.cdr.markForCheck();
157
163
  },
158
164
  error: () => {
159
165
  this.users = [];
160
166
  this.totalItems = 0;
161
167
  this.isSearching = false;
168
+ this.cdr.markForCheck();
162
169
  },
163
170
  });
164
171
  }
@@ -312,6 +319,7 @@ export class UserListComponent implements OnInit, OnDestroy {
312
319
  } catch {
313
320
  /* ignore */
314
321
  }
322
+ this.cdr.markForCheck();
315
323
  }
316
324
 
317
325
  isPinned(id?: string): boolean {
@@ -337,6 +345,7 @@ export class UserListComponent implements OnInit, OnDestroy {
337
345
  /* ignore */
338
346
  }
339
347
  this.activeFlagUserId = null;
348
+ this.cdr.markForCheck();
340
349
  }
341
350
 
342
351
  getFlagClass(id?: string): string {