@memberjunction/codegen-lib 2.114.0 → 2.116.0

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.
Files changed (39) hide show
  1. package/dist/Angular/angular-codegen.d.ts +50 -3
  2. package/dist/Angular/angular-codegen.d.ts.map +1 -1
  3. package/dist/Angular/angular-codegen.js +448 -93
  4. package/dist/Angular/angular-codegen.js.map +1 -1
  5. package/dist/Angular/related-entity-components.d.ts +6 -0
  6. package/dist/Angular/related-entity-components.d.ts.map +1 -1
  7. package/dist/Angular/related-entity-components.js +7 -0
  8. package/dist/Angular/related-entity-components.js.map +1 -1
  9. package/dist/Angular/user-view-grid-related-entity-component.d.ts.map +1 -1
  10. package/dist/Angular/user-view-grid-related-entity-component.js +12 -4
  11. package/dist/Angular/user-view-grid-related-entity-component.js.map +1 -1
  12. package/dist/Config/config.d.ts +0 -18
  13. package/dist/Config/config.d.ts.map +1 -1
  14. package/dist/Config/config.js +2 -2
  15. package/dist/Config/config.js.map +1 -1
  16. package/dist/Database/manage-metadata.d.ts +48 -7
  17. package/dist/Database/manage-metadata.d.ts.map +1 -1
  18. package/dist/Database/manage-metadata.js +358 -142
  19. package/dist/Database/manage-metadata.js.map +1 -1
  20. package/dist/Database/sql.d.ts.map +1 -1
  21. package/dist/Database/sql.js +134 -28
  22. package/dist/Database/sql.js.map +1 -1
  23. package/dist/Database/sql_codegen.d.ts.map +1 -1
  24. package/dist/Database/sql_codegen.js +3 -2
  25. package/dist/Database/sql_codegen.js.map +1 -1
  26. package/dist/Misc/action_subclasses_codegen.d.ts.map +1 -1
  27. package/dist/Misc/action_subclasses_codegen.js +8 -2
  28. package/dist/Misc/action_subclasses_codegen.js.map +1 -1
  29. package/dist/Misc/advanced_generation.d.ts +77 -22
  30. package/dist/Misc/advanced_generation.d.ts.map +1 -1
  31. package/dist/Misc/advanced_generation.js +248 -142
  32. package/dist/Misc/advanced_generation.js.map +1 -1
  33. package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
  34. package/dist/Misc/entity_subclasses_codegen.js +6 -1
  35. package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
  36. package/dist/runCodeGen.d.ts.map +1 -1
  37. package/dist/runCodeGen.js +7 -0
  38. package/dist/runCodeGen.js.map +1 -1
  39. package/package.json +10 -10
@@ -24,7 +24,7 @@ class AngularFormSectionInfo {
24
24
  */
25
25
  Name;
26
26
  /**
27
- * The generated HTML code for the tab
27
+ * The generated HTML code for the section (panel or tab)
28
28
  */
29
29
  TabCode;
30
30
  /**
@@ -63,6 +63,14 @@ class AngularFormSectionInfo {
63
63
  * The generation result for related entity components
64
64
  */
65
65
  GeneratedOutput;
66
+ /**
67
+ * The minimum sequence number from fields in this section (used for sorting)
68
+ */
69
+ MinSequence;
70
+ /**
71
+ * The unique camelCase key used for this section in the sectionsExpanded object
72
+ */
73
+ UniqueKey;
66
74
  }
67
75
  exports.AngularFormSectionInfo = AngularFormSectionInfo;
68
76
  /**
@@ -198,7 +206,6 @@ import { DropDownListModule } from '@progress/kendo-angular-dropdowns';
198
206
 
199
207
  // Import Generated Components
200
208
  ${componentImports.join('\n')}
201
- ${sections.filter(s => !s.IsRelatedEntity && s.ClassName && s.EntityClassName && s.FileNameWithoutExtension).map(s => `import { ${s.ClassName}, Load${s.ClassName} } from "./Entities/${s.EntityClassName}/sections/${s.FileNameWithoutExtension}"`).join('\n')}
202
209
  ${relatedEntityModuleImports.filter(remi => remi.library.trim().toLowerCase() !== '@memberjunction/ng-user-view-grid')
203
210
  .map(remi => `import { ${remi.modules.map(m => m).join(', ')} } from "${remi.library}"`)
204
211
  .join('\n')}
@@ -206,12 +213,11 @@ ${moduleCode}
206
213
 
207
214
  export function Load${modulePrefix}GeneratedForms() {
208
215
  // This function doesn't do much, but it calls each generated form's loader function
209
- // which in turn calls the sections for that generated form. Ultimately, those bits of
216
+ // which in turn calls the sections for that generated form. Ultimately, those bits of
210
217
  // code do NOTHING - the point is to prevent the code from being eliminated during tree shaking
211
218
  // since it is dynamically instantiated on demand, and the Angular compiler has no way to know that,
212
219
  // in production builds tree shaking will eliminate the code unless we do this
213
220
  ${componentNames.map(c => `Load${c.componentName}();`).join('\n ')}
214
- ${sections.filter(s => !s.IsRelatedEntity && s.ClassName).map(s => `Load${s.ClassName}();`).join('\n ')}
215
221
  }
216
222
  `;
217
223
  }
@@ -224,11 +230,11 @@ export function Load${modulePrefix}GeneratedForms() {
224
230
  * @returns Generated TypeScript code for all sub-modules and the master module
225
231
  */
226
232
  generateAngularModuleCode(componentNames, sections, maxComponentsPerModule, modulePrefix) {
227
- // this function breaks up the componentNames and sections up, we only want to have a max of maxComponentsPerModule components per module (of components and/or sections, doesn't matter)
228
- // so, we break up the list of components into sub-modules, and then generate the code for each sub-module
229
- // iterate through the componentNames first, then after we've exhausted those, then iterate through the sections
233
+ // this function breaks up the componentNames into sub-modules, we only want to have a max of maxComponentsPerModule components per module
234
+ // Note: sections are now inline in the HTML templates, so we don't include them in the module declarations
235
+ // Just use the component names - sections are inline HTML now, not separate components
230
236
  const simpleComponentNames = componentNames.map(c => c.componentName);
231
- const combinedArray = simpleComponentNames.concat(sections.map(s => s.ClassName));
237
+ const combinedArray = simpleComponentNames;
232
238
  const subModules = [];
233
239
  let currentComponentCount = 0;
234
240
  const subModuleStarter = `
@@ -325,7 +331,6 @@ export class ${this.SubModuleBaseName}${moduleNumber} { }
325
331
  */
326
332
  generateSingleEntityTypeScriptForAngular(entity, additionalSections, relatedEntitySections) {
327
333
  const entityObjectClass = entity.ClassName;
328
- const sectionImports = additionalSections.length > 0 ? additionalSections.map(s => `import { Load${s.ClassName} } from "./sections/${s.FileNameWithoutExtension}"`).join('\n') : '';
329
334
  // next, build a list of distinct imports at the library level and for components within the library
330
335
  const libs = relatedEntitySections.length > 0 ? relatedEntitySections.filter(s => s.GeneratedOutput && s.GeneratedOutput.Component && s.GeneratedOutput.Component.ImportPath)
331
336
  .map(s => {
@@ -350,17 +355,117 @@ export class ${this.SubModuleBaseName}${moduleNumber} { }
350
355
  });
351
356
  }
352
357
  });
353
- // nowe our libs array is good to go, we can generate the import statements for the libraries and the items within the libraries
358
+ // now our libs array is good to go, we can generate the import statements for the libraries and the items within the libraries
354
359
  const generationImports = distinctLibs.map(l => `import { ${l.items.join(", ")} } from "${l.lib}"`).join('\n');
355
360
  const generationInjectedCode = relatedEntitySections.length > 0 ?
356
361
  relatedEntitySections.filter(s => s.GeneratedOutput && s.GeneratedOutput.CodeOutput.length > 0)
357
362
  .map(s => s.GeneratedOutput.CodeOutput.split("\n").map(l => ` ${l}`).join("\n")).join('\n') : '';
363
+ // Generate unique keys for all sections FIRST, then use them everywhere
364
+ const sectionsWithoutTop = additionalSections.filter(s => s.Type !== core_1.GeneratedFormSectionType.Top && s.Name);
365
+ const allSections = [...sectionsWithoutTop, ...relatedEntitySections];
366
+ // Assign unique keys to each section
367
+ const usedKeys = new Set();
368
+ allSections.forEach((s) => {
369
+ let sectionKey = this.camelCase(s.Name);
370
+ // Ensure unique keys by tracking used keys and adding suffix for duplicates
371
+ let suffix = 1;
372
+ while (usedKeys.has(sectionKey)) {
373
+ sectionKey = this.camelCase(s.Name) + suffix++;
374
+ }
375
+ usedKeys.add(sectionKey);
376
+ s.UniqueKey = sectionKey; // Store the unique key with the section
377
+ });
378
+ // Now update all TabCode with the correct unique keys
379
+ allSections.forEach(s => {
380
+ if (s.TabCode && s.UniqueKey) {
381
+ // Replace placeholder camelCase keys with actual unique keys in the HTML
382
+ const placeholderKey = this.camelCase(s.Name);
383
+ if (placeholderKey !== s.UniqueKey) {
384
+ // Only replace if they're different (i.e., there was a duplicate)
385
+ const keyRegex = new RegExp(placeholderKey, 'g');
386
+ s.TabCode = s.TabCode.replace(keyRegex, s.UniqueKey);
387
+ }
388
+ }
389
+ });
390
+ // Now generate the sectionsExpanded object using the stored unique keys
391
+ const sectionsExpandedEntries = allSections.map((s, index) => {
392
+ // First 2 sections expanded by default, metadata and related entities collapsed
393
+ const isExpanded = index < 2 && !s.Name.toLowerCase().includes('metadata') && !s.IsRelatedEntity;
394
+ return ` ${s.UniqueKey}: ${isExpanded}`;
395
+ });
396
+ const sectionsExpandedObject = sectionsExpandedEntries.length > 0
397
+ ? `\n\n // Collapsible section state\n public sectionsExpanded = {\n${sectionsExpandedEntries.join(',\n')}\n };`
398
+ : '';
399
+ const toggleSectionMethod = sectionsExpandedEntries.length > 0
400
+ ? `\n\n public toggleSection(section: keyof typeof this.sectionsExpanded): void {\n this.sectionsExpanded[section] = !this.sectionsExpanded[section];\n }`
401
+ : '';
402
+ // Add expand all/collapse all/filter methods if there are 4+ sections
403
+ const totalSections = allSections.length;
404
+ // Add sectionRowCounts property for related entities
405
+ const hasRelatedEntities = relatedEntitySections.length > 0;
406
+ const sectionRowCountsProperty = hasRelatedEntities
407
+ ? `\n\n // Row counts for related entity sections (populated after grids load)\n public sectionRowCounts: { [key: string]: number } = {};`
408
+ : '';
409
+ const sectionUtilityMethods = totalSections >= 4 ? `\n
410
+ public expandAllSections(): void {
411
+ Object.keys(this.sectionsExpanded).forEach(key => {
412
+ this.sectionsExpanded[key as keyof typeof this.sectionsExpanded] = true;
413
+ });
414
+ }
415
+
416
+ public collapseAllSections(): void {
417
+ Object.keys(this.sectionsExpanded).forEach(key => {
418
+ this.sectionsExpanded[key as keyof typeof this.sectionsExpanded] = false;
419
+ });
420
+ }
421
+
422
+ public getExpandedCount(): number {
423
+ return Object.values(this.sectionsExpanded).filter(v => v === true).length;
424
+ }
425
+
426
+ public filterSections(event: Event): void {
427
+ const searchTerm = (event.target as HTMLInputElement).value.toLowerCase();
428
+ const panels = document.querySelectorAll('.form-card.collapsible-card');
429
+
430
+ panels.forEach((panel: Element) => {
431
+ const sectionName = panel.getAttribute('data-section-name') || '';
432
+ const fieldNames = panel.getAttribute('data-field-names') || '';
433
+
434
+ // Show section if search term matches section name OR any field name
435
+ const matches = sectionName.includes(searchTerm) || fieldNames.includes(searchTerm);
436
+
437
+ if (matches) {
438
+ panel.classList.remove('search-hidden');
439
+
440
+ // Add highlighting to matched text in section name
441
+ if (searchTerm && sectionName.includes(searchTerm)) {
442
+ const h3 = panel.querySelector('.collapsible-title h3 .section-name');
443
+ if (h3) {
444
+ const originalText = h3.textContent || '';
445
+ const regex = new RegExp(\`(\${searchTerm})\`, 'gi');
446
+ h3.innerHTML = originalText.replace(regex, '<span class="search-highlight">$1</span>');
447
+ }
448
+ }
449
+ } else {
450
+ panel.classList.add('search-hidden');
451
+ }
452
+ });
453
+
454
+ // Clear highlighting when search is empty
455
+ if (!searchTerm) {
456
+ panels.forEach((panel: Element) => {
457
+ const h3 = panel.querySelector('.collapsible-title h3 .section-name');
458
+ if (h3) {
459
+ h3.innerHTML = h3.textContent || '';
460
+ }
461
+ });
462
+ }
463
+ }` : '';
358
464
  return `import { Component } from '@angular/core';
359
465
  import { ${entityObjectClass}Entity } from '${entity.SchemaName === config_1.mjCoreSchema ? '@memberjunction/core-entities' : 'mj_generatedentities'}';
360
466
  import { RegisterClass } from '@memberjunction/global';
361
467
  import { BaseFormComponent } from '@memberjunction/ng-base-forms';
362
- ${sectionImports}${generationImports.length > 0 ? '\n' + generationImports : ''}
363
-
468
+ ${generationImports.length > 0 ? generationImports + '\n' : ''}
364
469
  @RegisterClass(BaseFormComponent, '${entity.Name}') // Tell MemberJunction about this class
365
470
  @Component({
366
471
  selector: 'gen-${entity.ClassName.toLowerCase()}-form',
@@ -368,11 +473,11 @@ ${sectionImports}${generationImports.length > 0 ? '\n' + generationImports : ''}
368
473
  styleUrls: ['../../../../shared/form-styles.css']
369
474
  })
370
475
  export class ${entity.ClassName}FormComponent extends BaseFormComponent {
371
- public record!: ${entityObjectClass}Entity;${generationInjectedCode.length > 0 ? '\n' + generationInjectedCode : ''}
372
- }
476
+ public record!: ${entityObjectClass}Entity;${generationInjectedCode.length > 0 ? '\n' + generationInjectedCode : ''}${sectionsExpandedObject}${sectionRowCountsProperty}${toggleSectionMethod}${sectionUtilityMethods}
477
+ }
373
478
 
374
479
  export function Load${entity.ClassName}FormComponent() {
375
- ${additionalSections.map(s => `Load${s.ClassName}();`).join('\n ')}
480
+ // does nothing, but called to prevent tree-shaking from eliminating this component from the build
376
481
  }
377
482
  `;
378
483
  }
@@ -401,44 +506,71 @@ export function Load${entity.ClassName}FormComponent() {
401
506
  * @param sections Array of existing sections
402
507
  * @param type The type of section to add
403
508
  * @param name The name of the section
509
+ * @param fieldSequence Optional sequence number of the field (used to track minimum sequence for sorting)
404
510
  */
405
- AddSectionIfNeeded(entity, sections, type, name) {
511
+ AddSectionIfNeeded(entity, sections, type, name, fieldSequence) {
406
512
  const section = sections.find(s => s.Name === name && s.Type === type);
407
- const fName = `${this.stripWhiteSpace(name.toLowerCase())}.component`;
408
- if (!section)
513
+ const fName = `${this.sanitizeFilename(name)}.component`;
514
+ if (!section) {
409
515
  sections.push({
410
516
  Type: type,
411
517
  Name: name,
412
518
  FileName: `${fName}.ts`,
413
519
  ComponentCode: '',
414
- ClassName: `${entity.ClassName}${this.stripWhiteSpace(name)}Component`,
520
+ ClassName: `${entity.ClassName}${this.pascalCase(name)}Component`,
415
521
  TabCode: '',
416
522
  Fields: [],
417
523
  EntityClassName: entity.ClassName,
418
- FileNameWithoutExtension: fName
524
+ FileNameWithoutExtension: fName,
525
+ MinSequence: fieldSequence
419
526
  });
527
+ }
528
+ else if (fieldSequence != null && (section.MinSequence == null || fieldSequence < section.MinSequence)) {
529
+ // Update the minimum sequence if this field has a lower sequence
530
+ section.MinSequence = fieldSequence;
531
+ }
420
532
  }
421
533
  /**
422
534
  * Generates additional form sections based on entity field metadata
423
535
  * @param entity The entity to generate sections for
424
536
  * @param startIndex Starting index for tab ordering
537
+ * @param categoryIcons Optional map of category names to Font Awesome icon classes
425
538
  * @returns Array of generated form sections
426
539
  */
427
- generateAngularAdditionalSections(entity, startIndex) {
540
+ generateAngularAdditionalSections(entity, startIndex, categoryIcons) {
428
541
  const sections = [];
429
542
  let index = startIndex;
430
543
  const sortedFields = (0, util_1.sortBySequenceAndCreatedAt)(entity.Fields);
431
544
  for (const field of sortedFields) {
432
545
  if (field.IncludeInGeneratedForm) {
433
546
  if (field.GeneratedFormSectionType === core_1.GeneratedFormSectionType.Category && field.Category && field.Category !== '' && field.IncludeInGeneratedForm)
434
- this.AddSectionIfNeeded(entity, sections, core_1.GeneratedFormSectionType.Category, field.Category);
547
+ this.AddSectionIfNeeded(entity, sections, core_1.GeneratedFormSectionType.Category, field.Category, field.Sequence);
435
548
  else if (field.GeneratedFormSectionType === core_1.GeneratedFormSectionType.Details)
436
- this.AddSectionIfNeeded(entity, sections, core_1.GeneratedFormSectionType.Details, "Details");
549
+ this.AddSectionIfNeeded(entity, sections, core_1.GeneratedFormSectionType.Details, "Details", field.Sequence);
437
550
  else if (field.GeneratedFormSectionType === core_1.GeneratedFormSectionType.Top)
438
- this.AddSectionIfNeeded(entity, sections, core_1.GeneratedFormSectionType.Top, "Top");
551
+ this.AddSectionIfNeeded(entity, sections, core_1.GeneratedFormSectionType.Top, "Top", field.Sequence);
439
552
  }
440
553
  }
441
- // now we have a distinct list of section names set, generate HTML for each section
554
+ // Sort sections by minimum sequence (Top sections first, System sections last, then by MinSequence)
555
+ sections.sort((a, b) => {
556
+ // Top sections always first
557
+ if (a.Type === core_1.GeneratedFormSectionType.Top)
558
+ return -1;
559
+ if (b.Type === core_1.GeneratedFormSectionType.Top)
560
+ return 1;
561
+ // System sections always last (after related entities)
562
+ const aIsSystem = a.Name.toLowerCase() === 'system' || a.Name.toLowerCase() === 'system metadata';
563
+ const bIsSystem = b.Name.toLowerCase() === 'system' || b.Name.toLowerCase() === 'system metadata';
564
+ if (aIsSystem && !bIsSystem)
565
+ return 1;
566
+ if (!aIsSystem && bIsSystem)
567
+ return -1;
568
+ // Otherwise sort by sequence
569
+ const aSeq = a.MinSequence ?? Number.MAX_SAFE_INTEGER;
570
+ const bSeq = b.MinSequence ?? Number.MAX_SAFE_INTEGER;
571
+ return aSeq - bSeq;
572
+ });
573
+ // now we have a distinct list of section names set, generate HTML for each section
442
574
  let sectionIndex = 0;
443
575
  for (const section of sections) {
444
576
  let sectionName = '';
@@ -451,44 +583,40 @@ export function Load${entity.ClassName}FormComponent() {
451
583
  sectionName = this.stripWhiteSpace(section.Name.toLowerCase());
452
584
  else if (section.Type === core_1.GeneratedFormSectionType.Details)
453
585
  sectionName = 'details';
454
- section.TabCode = `${sectionIndex++ > 0 ? '\n ' : ''}<mj-tab Name="${section.Name}">
455
- ${section.Name}
456
- </mj-tab>
457
- <mj-tab-body>
458
- <mj-form-section
459
- Entity="${entity.Name}"
460
- Section="${this.stripWhiteSpace(section.Name.toLowerCase())}"
461
- [record]="record"
462
- [EditMode]="this.EditMode">
463
- </mj-form-section>
464
- </mj-tab-body>`;
465
- }
466
- const formHTML = this.generateSectionHTMLForAngular(entity, section);
467
- section.ComponentCode = `import { Component, Input } from '@angular/core';
468
- import { RegisterClass } from '@memberjunction/global';
469
- import { BaseFormSectionComponent } from '@memberjunction/ng-base-forms';
470
- import { ${entity.ClassName}Entity } from '${entity.SchemaName === config_1.mjCoreSchema ? '@memberjunction/core-entities' : 'mj_generatedentities'}';
471
-
472
- @RegisterClass(BaseFormSectionComponent, '${entity.Name}.${sectionName}') // Tell MemberJunction about this class
473
- @Component({
474
- selector: 'gen-${entity.ClassName.toLowerCase()}-form-${sectionName}',
475
- styleUrls: ['../../../../../shared/form-styles.css'],
476
- template: \`<div *ngIf="this.record">
477
- <div class="record-form">
586
+ // Generate collapsible panel HTML inline instead of using separate components
587
+ const formHTML = this.generateSectionHTMLForAngular(entity, section);
588
+ // Use category-specific icon from LLM if available, otherwise fall back to keyword matching
589
+ const icon = (categoryIcons && categoryIcons[section.Name]) || this.getIconForCategory(section.Name);
590
+ // NOTE: We'll set the UniqueKey later in generateSingleEntityTypeScriptForAngular()
591
+ // For now, just use a placeholder that will be replaced
592
+ const sectionKey = this.camelCase(section.Name);
593
+ // Build field names string for search functionality (includes both CodeName and DisplayName)
594
+ const fieldSearchTerms = section.Fields ? section.Fields.map(f => {
595
+ const terms = [f.CodeName.toLowerCase()];
596
+ if (f.DisplayName && f.DisplayName.toLowerCase() !== f.CodeName.toLowerCase()) {
597
+ terms.push(f.DisplayName.toLowerCase());
598
+ }
599
+ return terms.join(' ');
600
+ }).join(' ') : '';
601
+ section.TabCode = `${sectionIndex > 0 ? '\n ' : ''}<!-- ${section.Name} Section -->
602
+ <div class="form-card collapsible-card" data-section-name="${section.Name.toLowerCase()}" data-field-names="${fieldSearchTerms}">
603
+ <div class="collapsible-header" (click)="toggleSection('${sectionKey}')" role="button" tabindex="0">
604
+ <div class="collapsible-title">
605
+ <i class="${icon}"></i>
606
+ <h3><span class="section-name">${section.Name}</span></h3>
607
+ </div>
608
+ <div class="collapse-icon">
609
+ <i [class]="sectionsExpanded.${sectionKey} ? 'fa fa-chevron-up' : 'fa fa-chevron-down'"></i>
610
+ </div>
611
+ </div>
612
+ <div class="collapsible-body" [class.collapsed]="!sectionsExpanded.${sectionKey}">
613
+ <div class="form-body">
478
614
  ${formHTML}
479
- </div>
480
- </div>
481
- \`
482
- })
483
- export class ${entity.ClassName}${this.stripWhiteSpace(section.Name)}Component extends BaseFormSectionComponent {
484
- @Input() override record!: ${entity.ClassName}Entity;
485
- @Input() override EditMode: boolean = false;
486
- }
487
-
488
- export function Load${entity.ClassName}${this.stripWhiteSpace(section.Name)}Component() {
489
- // does nothing, but called in order to prevent tree-shaking from eliminating this component from the build
490
- }
491
- `;
615
+ </div>
616
+ </div>
617
+ </div>`;
618
+ sectionIndex++;
619
+ }
492
620
  if (section.Type !== core_1.GeneratedFormSectionType.Top)
493
621
  index++; // don't increment the tab index for TOP AREA, becuse it won't be rendered as a tab
494
622
  }
@@ -632,39 +760,74 @@ export function Load${entity.ClassName}${this.stripWhiteSpace(section.Name)}Comp
632
760
  async generateRelatedEntityTabs(entity, startIndex, contextUser) {
633
761
  const md = new core_1.Metadata();
634
762
  const tabs = [];
635
- const sortedRelatedEntities = (0, util_1.sortBySequenceAndCreatedAt)(entity.RelatedEntities.filter(re => re.DisplayInForm)); // only show related entities that are marked to display in the form and sort by sequence, then by creation date
763
+ // Sort related entities by Sequence (user's explicit ordering), then by RelatedEntity name (stable tiebreaker)
764
+ const sortedRelatedEntities = entity.RelatedEntities
765
+ .filter(re => re.DisplayInForm)
766
+ .sort((a, b) => {
767
+ if (a.Sequence !== b.Sequence) {
768
+ return a.Sequence - b.Sequence;
769
+ }
770
+ return a.RelatedEntity.localeCompare(b.RelatedEntity);
771
+ });
636
772
  let index = startIndex;
637
773
  for (const relatedEntity of sortedRelatedEntities) {
638
774
  const tabName = this.generateRelatedEntityTabName(relatedEntity, sortedRelatedEntities);
639
775
  let icon = '';
640
- switch (relatedEntity.DisplayIconType) {
641
- case 'Custom':
642
- if (relatedEntity.DisplayIcon && relatedEntity.DisplayIcon.length > 0)
643
- icon = `<span class="${relatedEntity.DisplayIcon} tab-header-icon"></span>`;
644
- break;
645
- case 'Related Entity Icon':
646
- const re = md.Entities.find(e => e.ID === relatedEntity.RelatedEntityID);
647
- if (re && re.Icon && re.Icon.length > 0)
648
- icon = `<span class="${re.Icon} tab-header-icon"></span>`;
649
- break;
650
- default:
651
- // none
652
- break;
776
+ let iconClass = '';
777
+ // First, check for custom icon
778
+ if (relatedEntity.DisplayIconType === 'Custom' && relatedEntity.DisplayIcon && relatedEntity.DisplayIcon.length > 0) {
779
+ icon = `<span class="${relatedEntity.DisplayIcon} tab-header-icon"></span>`;
780
+ iconClass = relatedEntity.DisplayIcon;
781
+ }
782
+ // If no custom icon, try to use the related entity's icon
783
+ else {
784
+ const re = md.Entities.find(e => e.ID === relatedEntity.RelatedEntityID);
785
+ if (re && re.Icon && re.Icon.length > 0) {
786
+ icon = `<span class="${re.Icon} tab-header-icon"></span>`;
787
+ iconClass = re.Icon;
788
+ }
789
+ else {
790
+ // Fall back to default table icon
791
+ iconClass = 'fa fa-table';
792
+ }
653
793
  }
794
+ // Calculate section key before generation (may be replaced later if duplicate)
795
+ const sectionKey = this.camelCase(tabName);
654
796
  const component = await related_entity_components_1.RelatedEntityDisplayComponentGeneratorBase.GetComponent(relatedEntity, contextUser);
655
797
  const generateResults = await component.Generate({
656
798
  Entity: entity,
657
799
  RelationshipInfo: relatedEntity,
658
- TabName: tabName
800
+ TabName: tabName,
801
+ SectionKey: sectionKey // Pass section key for IsCurrentSection() calls
659
802
  });
660
- // now for each newline add a series of tabs to map to the indentation we need for pretty formatting
661
- const componentCodeWithTabs = generateResults.TemplateOutput.split('\n').map(l => ` ${l}`).join('\n');
662
- const tabCode = `${index > 0 ? '\n' : ''} <mj-tab Name="${tabName}" [Visible]="record.IsSaved">
663
- ${icon}${tabName}
664
- </mj-tab>
665
- <mj-tab-body>
666
- ${componentCodeWithTabs}
667
- </mj-tab-body>`;
803
+ // Add proper indentation for collapsible panel body
804
+ const componentCodeWithIndent = generateResults.TemplateOutput.split('\n').map(l => ` ${l}`).join('\n');
805
+ // For related entities, use the related entity name as searchable term
806
+ const relatedEntitySearchTerms = relatedEntity.RelatedEntity.toLowerCase();
807
+ const tabCode = `${index > 0 ? '\n ' : ''}<!-- ${tabName} Section -->
808
+ <div class="form-card collapsible-card related-entity" data-section-name="${tabName.toLowerCase()}" data-field-names="${relatedEntitySearchTerms}" data-section-key="${sectionKey}">
809
+ <div class="collapsible-header" (click)="toggleSection('${sectionKey}')" role="button" tabindex="0">
810
+ <div class="collapsible-title">
811
+ <i class="${iconClass}"></i>
812
+ <h3>
813
+ <span class="section-name">${tabName}</span>
814
+ <span class="row-count-badge"
815
+ *ngIf="sectionRowCounts?.['${sectionKey}'] !== undefined"
816
+ [class.zero-rows]="sectionRowCounts['${sectionKey}'] === 0">
817
+ {{sectionRowCounts['${sectionKey}']}}
818
+ </span>
819
+ </h3>
820
+ </div>
821
+ <div class="collapse-icon">
822
+ <i [class]="sectionsExpanded.${sectionKey} ? 'fa fa-chevron-up' : 'fa fa-chevron-down'"></i>
823
+ </div>
824
+ </div>
825
+ <div class="collapsible-body" [class.collapsed]="!sectionsExpanded.${sectionKey}" *ngIf="record.IsSaved">
826
+ <div class="form-body">
827
+ ${componentCodeWithIndent}
828
+ </div>
829
+ </div>
830
+ </div>`;
668
831
  tabs.push({
669
832
  Type: core_1.GeneratedFormSectionType.Category,
670
833
  IsRelatedEntity: true,
@@ -686,6 +849,129 @@ ${componentCodeWithTabs}
686
849
  stripWhiteSpace(s) {
687
850
  return s.replace(/\s/g, '');
688
851
  }
852
+ /**
853
+ * Converts a string to camelCase and sanitizes it for use as a JavaScript identifier
854
+ * @param str The string to convert
855
+ * @returns String in camelCase format, safe for use as object key or variable name
856
+ */
857
+ camelCase(str) {
858
+ // First, replace non-alphanumeric characters (except spaces) with spaces
859
+ let sanitized = str.replace(/[^a-zA-Z0-9\s]/g, ' ');
860
+ // Convert to camelCase
861
+ let result = sanitized
862
+ .replace(/\s(.)/g, (match, char) => char.toUpperCase())
863
+ .replace(/\s/g, '')
864
+ .replace(/^(.)/, (match, char) => char.toLowerCase());
865
+ // If starts with a digit, prefix with underscore
866
+ if (/^\d/.test(result)) {
867
+ result = '_' + result;
868
+ }
869
+ // If result is empty (all special chars), use a default
870
+ if (result.length === 0) {
871
+ result = 'section';
872
+ }
873
+ return result;
874
+ }
875
+ /**
876
+ * Converts a string to PascalCase and sanitizes it for use as a class name
877
+ * @param str The string to convert
878
+ * @returns String in PascalCase format, safe for use as a class name
879
+ */
880
+ pascalCase(str) {
881
+ // First, replace non-alphanumeric characters (except spaces) with spaces
882
+ let sanitized = str.replace(/[^a-zA-Z0-9\s]/g, ' ');
883
+ // Convert to PascalCase (capitalize first letter of each word)
884
+ let result = sanitized
885
+ .replace(/\s(.)/g, (match, char) => char.toUpperCase())
886
+ .replace(/\s/g, '')
887
+ .replace(/^(.)/, (match, char) => char.toUpperCase());
888
+ // If starts with a digit, prefix with underscore
889
+ if (/^\d/.test(result)) {
890
+ result = '_' + result;
891
+ }
892
+ // If result is empty (all special chars), use a default
893
+ if (result.length === 0) {
894
+ result = 'Section';
895
+ }
896
+ return result;
897
+ }
898
+ /**
899
+ * Sanitizes a string to create a valid filename in lowercase format.
900
+ * Removes all non-alphanumeric characters (except spaces) and converts to lowercase.
901
+ * Used for creating component filenames that are safe across all file systems.
902
+ *
903
+ * Example: "Timeline & Budget" → "timelinebudget"
904
+ *
905
+ * @param str The string to sanitize
906
+ * @returns A sanitized lowercase filename string
907
+ */
908
+ sanitizeFilename(str) {
909
+ // Remove all non-alphanumeric characters (except spaces)
910
+ let sanitized = str.replace(/[^a-zA-Z0-9\s]/g, '');
911
+ // Convert to lowercase and remove all spaces
912
+ let result = sanitized.toLowerCase().replace(/\s/g, '');
913
+ // If result is empty (all special chars), use a default
914
+ if (result.length === 0) {
915
+ result = 'section';
916
+ }
917
+ return result;
918
+ }
919
+ /**
920
+ * Maps category names to appropriate Font Awesome icon classes
921
+ * @param category The category name to map
922
+ * @returns Font Awesome icon class string
923
+ */
924
+ getIconForCategory(category) {
925
+ const lowerCategory = category.toLowerCase();
926
+ // Address/Location categories
927
+ if (lowerCategory.includes('address') || lowerCategory.includes('location')) {
928
+ return 'fa fa-map-marker-alt';
929
+ }
930
+ // Contact Information
931
+ if (lowerCategory.includes('contact')) {
932
+ return 'fa fa-address-card';
933
+ }
934
+ // Financial/Pricing categories
935
+ if (lowerCategory.includes('pric') || lowerCategory.includes('cost') ||
936
+ lowerCategory.includes('payment') || lowerCategory.includes('financial') ||
937
+ lowerCategory.includes('billing')) {
938
+ return 'fa fa-dollar-sign';
939
+ }
940
+ // Date/Time categories
941
+ if (lowerCategory.includes('date') || lowerCategory.includes('time') ||
942
+ lowerCategory.includes('schedule')) {
943
+ return 'fa fa-calendar';
944
+ }
945
+ // Status/State categories
946
+ if (lowerCategory.includes('status') || lowerCategory.includes('state')) {
947
+ return 'fa fa-flag';
948
+ }
949
+ // Metadata/Technical categories
950
+ if (lowerCategory.includes('metadata') || lowerCategory.includes('technical') ||
951
+ lowerCategory.includes('system')) {
952
+ return 'fa fa-cog';
953
+ }
954
+ // Description/Details categories
955
+ if (lowerCategory.includes('description') || lowerCategory.includes('detail')) {
956
+ return 'fa fa-align-left';
957
+ }
958
+ // Settings/Configuration categories
959
+ if (lowerCategory.includes('setting') || lowerCategory.includes('config') ||
960
+ lowerCategory.includes('preference')) {
961
+ return 'fa fa-sliders-h';
962
+ }
963
+ // Shipping/Delivery categories
964
+ if (lowerCategory.includes('ship') || lowerCategory.includes('delivery')) {
965
+ return 'fa fa-truck';
966
+ }
967
+ // User/Person categories
968
+ if (lowerCategory.includes('user') || lowerCategory.includes('person') ||
969
+ lowerCategory.includes('customer') || lowerCategory.includes('employee')) {
970
+ return 'fa fa-user';
971
+ }
972
+ // Default icon for uncategorized sections
973
+ return 'fa fa-info-circle';
974
+ }
689
975
  /**
690
976
  * Generates the complete HTML template for a single entity form
691
977
  * @param entity The entity to generate HTML for
@@ -693,8 +979,22 @@ ${componentCodeWithTabs}
693
979
  * @returns Promise resolving to an object containing the HTML code and section information
694
980
  */
695
981
  async generateSingleEntityHTMLForAngular(entity, contextUser) {
982
+ // Load category icons from EntitySetting if available
983
+ let categoryIcons;
984
+ const entitySettings = entity.Settings;
985
+ if (entitySettings) {
986
+ const iconSetting = entitySettings.find((s) => s.Name === 'FieldCategoryIcons');
987
+ if (iconSetting && iconSetting.Value) {
988
+ try {
989
+ categoryIcons = JSON.parse(iconSetting.Value);
990
+ }
991
+ catch (e) {
992
+ // Invalid JSON, ignore and fall back to keyword matching
993
+ }
994
+ }
995
+ }
696
996
  const topArea = this.generateTopAreaHTMLForAngular(entity);
697
- const additionalSections = this.generateAngularAdditionalSections(entity, 0);
997
+ const additionalSections = this.generateAngularAdditionalSections(entity, 0, categoryIcons);
698
998
  // calc ending index for additional sections so we can pass taht into the related entity tabs because they need to start incrementally up from there...
699
999
  const endingIndex = additionalSections && additionalSections.length ? (topArea && topArea.length > 0 ? additionalSections.length - 1 : additionalSections.length) : 0;
700
1000
  const relatedEntitySections = await this.generateRelatedEntityTabs(entity, endingIndex, contextUser);
@@ -710,15 +1010,15 @@ ${componentCodeWithTabs}
710
1010
  * @returns Generated HTML with splitter layout
711
1011
  */
712
1012
  generateSingleEntityHTMLWithSplitterForAngular(topArea, additionalSections, relatedEntitySections) {
713
- const htmlCode = `<div class="record-form-container" >
714
- <form *ngIf="record" class="record-form" #form="ngForm" >
1013
+ const htmlCode = `<div class="record-form-container">
1014
+ <form *ngIf="record" class="record-form" #form="ngForm">
715
1015
  <mj-form-toolbar [form]="this"></mj-form-toolbar>
716
- <kendo-splitter orientation="vertical" (layoutChange)="splitterLayoutChange()" >
1016
+ <kendo-splitter orientation="vertical" (layoutChange)="splitterLayoutChange()">
717
1017
  <kendo-splitter-pane [collapsible]="true" [size]="TopAreaHeight">
718
1018
  ${this.innerTopAreaHTML(topArea)}
719
1019
  </kendo-splitter-pane>
720
1020
  <kendo-splitter-pane>
721
- ${this.innerTabStripHTML(additionalSections, relatedEntitySections)}
1021
+ ${this.innerCollapsiblePanelsHTML(additionalSections, relatedEntitySections)}
722
1022
  </kendo-splitter-pane>
723
1023
  </kendo-splitter>
724
1024
  </form>
@@ -740,6 +1040,61 @@ ${this.innerTabStripHTML(additionalSections, relatedEntitySections)}
740
1040
  </div>`;
741
1041
  }
742
1042
  /**
1043
+ * Generates the HTML for collapsible panels containing all form sections
1044
+ * @param additionalSections Array of field-based form sections
1045
+ * @param relatedEntitySections Array of related entity sections
1046
+ * @returns HTML string for all collapsible panels
1047
+ */
1048
+ innerCollapsiblePanelsHTML(additionalSections, relatedEntitySections) {
1049
+ // Filter out Top sections as they're handled separately
1050
+ const sectionsToRender = additionalSections.filter(s => s.Type !== core_1.GeneratedFormSectionType.Top);
1051
+ const totalSections = sectionsToRender.length + relatedEntitySections.length;
1052
+ // Only show section controls if there are 4+ sections
1053
+ const showControls = totalSections >= 4;
1054
+ const controlsHTML = showControls ? ` <div class="form-section-controls">
1055
+ <div class="control-group">
1056
+ <button (click)="expandAllSections()" title="Expand all sections">
1057
+ <i class="fa fa-expand-alt"></i>Expand All
1058
+ </button>
1059
+ <button (click)="collapseAllSections()" title="Collapse all sections">
1060
+ <i class="fa fa-compress-alt"></i>Collapse All
1061
+ </button>
1062
+ </div>
1063
+ <input type="text"
1064
+ class="section-search"
1065
+ placeholder="Search sections..."
1066
+ (input)="filterSections($event)"
1067
+ #sectionSearch>
1068
+ <span class="section-count">
1069
+ <span class="section-count-badge">{{getExpandedCount()}}</span> of ${totalSections} expanded
1070
+ </span>
1071
+ </div>
1072
+ ` : '';
1073
+ // Combine field sections and related entity sections into panels, respecting DisplayLocation
1074
+ if (relatedEntitySections.length > 0) {
1075
+ const relatedEntityBeforePanels = relatedEntitySections.filter(s => s.RelatedEntityDisplayLocation === 'Before Field Tabs');
1076
+ const relatedEntityAfterPanels = relatedEntitySections.filter(s => s.RelatedEntityDisplayLocation === 'After Field Tabs');
1077
+ // Wrap related entity sections in grid container
1078
+ const beforePanelsHTML = relatedEntityBeforePanels.length > 0
1079
+ ? ` <div class="related-entity-grid">\n${relatedEntityBeforePanels.map(s => s.TabCode).join('\n')}\n </div>`
1080
+ : '';
1081
+ const afterPanelsHTML = relatedEntityAfterPanels.length > 0
1082
+ ? ` <div class="related-entity-grid">\n${relatedEntityAfterPanels.map(s => s.TabCode).join('\n')}\n </div>`
1083
+ : '';
1084
+ const fieldPanelsHTML = sectionsToRender.map(s => s.TabCode).join('\n');
1085
+ return `${controlsHTML} <div class="form-panels-container">
1086
+ ${beforePanelsHTML}${beforePanelsHTML && fieldPanelsHTML ? '\n' : ''}${fieldPanelsHTML}${fieldPanelsHTML && afterPanelsHTML ? '\n' : ''}${afterPanelsHTML}
1087
+ </div>`;
1088
+ }
1089
+ else {
1090
+ // No related entities, just show field panels
1091
+ return `${controlsHTML} <div class="form-panels-container">
1092
+ ${sectionsToRender.map(s => s.TabCode).join('\n')}
1093
+ </div>`;
1094
+ }
1095
+ }
1096
+ /**
1097
+ * @deprecated Use innerCollapsiblePanelsHTML instead
743
1098
  * Generates the HTML for the tab strip containing all form sections
744
1099
  * @param additionalSections Array of field-based form sections
745
1100
  * @param relatedEntitySections Array of related entity sections
@@ -764,11 +1119,11 @@ ${this.innerTabStripHTML(additionalSections, relatedEntitySections)}
764
1119
  * @returns Generated HTML without splitter layout
765
1120
  */
766
1121
  generateSingleEntityHTMLWithOUTSplitterForAngular(topArea, additionalSections, relatedEntitySections) {
767
- const htmlCode = `<div class="record-form-container" >
768
- <form *ngIf="record" class="record-form" #form="ngForm" >
1122
+ const htmlCode = `<div class="record-form-container">
1123
+ <form *ngIf="record" class="record-form" #form="ngForm">
769
1124
  <mj-form-toolbar [form]="this"></mj-form-toolbar>
770
1125
  ${this.innerTopAreaHTML(topArea)}
771
- ${this.innerTabStripHTML(additionalSections, relatedEntitySections)}
1126
+ ${this.innerCollapsiblePanelsHTML(additionalSections, relatedEntitySections)}
772
1127
  </form>
773
1128
  </div>
774
1129
  `;