@memberjunction/codegen-lib 2.115.0 → 2.117.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 (40) 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 +364 -118
  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 +1 -1
  10. package/dist/Angular/user-view-grid-related-entity-component.d.ts.map +1 -1
  11. package/dist/Angular/user-view-grid-related-entity-component.js +14 -12
  12. package/dist/Angular/user-view-grid-related-entity-component.js.map +1 -1
  13. package/dist/Config/config.d.ts +0 -18
  14. package/dist/Config/config.d.ts.map +1 -1
  15. package/dist/Config/config.js +2 -2
  16. package/dist/Config/config.js.map +1 -1
  17. package/dist/Database/manage-metadata.d.ts +48 -7
  18. package/dist/Database/manage-metadata.d.ts.map +1 -1
  19. package/dist/Database/manage-metadata.js +383 -146
  20. package/dist/Database/manage-metadata.js.map +1 -1
  21. package/dist/Database/sql.d.ts.map +1 -1
  22. package/dist/Database/sql.js +134 -28
  23. package/dist/Database/sql.js.map +1 -1
  24. package/dist/Database/sql_codegen.d.ts.map +1 -1
  25. package/dist/Database/sql_codegen.js +3 -2
  26. package/dist/Database/sql_codegen.js.map +1 -1
  27. package/dist/Misc/action_subclasses_codegen.d.ts.map +1 -1
  28. package/dist/Misc/action_subclasses_codegen.js +5 -2
  29. package/dist/Misc/action_subclasses_codegen.js.map +1 -1
  30. package/dist/Misc/advanced_generation.d.ts +80 -22
  31. package/dist/Misc/advanced_generation.d.ts.map +1 -1
  32. package/dist/Misc/advanced_generation.js +248 -142
  33. package/dist/Misc/advanced_generation.js.map +1 -1
  34. package/dist/Misc/entity_subclasses_codegen.d.ts.map +1 -1
  35. package/dist/Misc/entity_subclasses_codegen.js +6 -1
  36. package/dist/Misc/entity_subclasses_codegen.js.map +1 -1
  37. package/dist/runCodeGen.d.ts.map +1 -1
  38. package/dist/runCodeGen.js +7 -0
  39. package/dist/runCodeGen.js.map +1 -1
  40. 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
  /**
@@ -99,18 +107,12 @@ class AngularClientGeneratorBase {
99
107
  const tsCode = this.generateSingleEntityTypeScriptForAngular(entity, additionalSections, relatedEntitySections);
100
108
  fs_1.default.writeFileSync(path_1.default.join(thisEntityPath, `${entity.ClassName.toLowerCase()}.form.component.ts`), tsCode);
101
109
  fs_1.default.writeFileSync(path_1.default.join(thisEntityPath, `${entity.ClassName.toLowerCase()}.form.component.html`), htmlCode);
110
+ // Sections are now inline in HTML templates, no separate files needed
111
+ // Just track them for module generation purposes
102
112
  if (additionalSections.length > 0) {
103
- const sectionPath = path_1.default.join(thisEntityPath, 'sections');
104
- if (!fs_1.default.existsSync(sectionPath))
105
- fs_1.default.mkdirSync(sectionPath, { recursive: true }); // create the directory if it doesn't exist
106
- for (let j = 0; j < additionalSections.length; ++j) {
107
- const section = additionalSections[j];
108
- // Only write file if FileName and ComponentCode are populated
109
- if (section.FileName && section.ComponentCode) {
110
- fs_1.default.writeFileSync(path_1.default.join(sectionPath, section.FileName), section.ComponentCode);
111
- }
112
- sections.push(section); // add the entity's secitons one by one to the master/global list of sections
113
- }
113
+ additionalSections.forEach(section => {
114
+ sections.push(section);
115
+ });
114
116
  }
115
117
  const componentName = `${entity.ClassName}FormComponent`;
116
118
  componentImports.push(`import { ${componentName}, Load${componentName} } from "./Entities/${entity.ClassName}/${entity.ClassName.toLowerCase()}.form.component";`);
@@ -198,7 +200,6 @@ import { DropDownListModule } from '@progress/kendo-angular-dropdowns';
198
200
 
199
201
  // Import Generated Components
200
202
  ${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
203
  ${relatedEntityModuleImports.filter(remi => remi.library.trim().toLowerCase() !== '@memberjunction/ng-user-view-grid')
203
204
  .map(remi => `import { ${remi.modules.map(m => m).join(', ')} } from "${remi.library}"`)
204
205
  .join('\n')}
@@ -206,12 +207,11 @@ ${moduleCode}
206
207
 
207
208
  export function Load${modulePrefix}GeneratedForms() {
208
209
  // 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
210
+ // which in turn calls the sections for that generated form. Ultimately, those bits of
210
211
  // code do NOTHING - the point is to prevent the code from being eliminated during tree shaking
211
212
  // since it is dynamically instantiated on demand, and the Angular compiler has no way to know that,
212
213
  // in production builds tree shaking will eliminate the code unless we do this
213
214
  ${componentNames.map(c => `Load${c.componentName}();`).join('\n ')}
214
- ${sections.filter(s => !s.IsRelatedEntity && s.ClassName).map(s => `Load${s.ClassName}();`).join('\n ')}
215
215
  }
216
216
  `;
217
217
  }
@@ -224,11 +224,11 @@ export function Load${modulePrefix}GeneratedForms() {
224
224
  * @returns Generated TypeScript code for all sub-modules and the master module
225
225
  */
226
226
  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
227
+ // this function breaks up the componentNames into sub-modules, we only want to have a max of maxComponentsPerModule components per module
228
+ // Note: sections are now inline in the HTML templates, so we don't include them in the module declarations
229
+ // Just use the component names - sections are inline HTML now, not separate components
230
230
  const simpleComponentNames = componentNames.map(c => c.componentName);
231
- const combinedArray = simpleComponentNames.concat(sections.map(s => s.ClassName));
231
+ const combinedArray = simpleComponentNames;
232
232
  const subModules = [];
233
233
  let currentComponentCount = 0;
234
234
  const subModuleStarter = `
@@ -325,7 +325,6 @@ export class ${this.SubModuleBaseName}${moduleNumber} { }
325
325
  */
326
326
  generateSingleEntityTypeScriptForAngular(entity, additionalSections, relatedEntitySections) {
327
327
  const entityObjectClass = entity.ClassName;
328
- const sectionImports = additionalSections.length > 0 ? additionalSections.map(s => `import { Load${s.ClassName} } from "./sections/${s.FileNameWithoutExtension}"`).join('\n') : '';
329
328
  // next, build a list of distinct imports at the library level and for components within the library
330
329
  const libs = relatedEntitySections.length > 0 ? relatedEntitySections.filter(s => s.GeneratedOutput && s.GeneratedOutput.Component && s.GeneratedOutput.Component.ImportPath)
331
330
  .map(s => {
@@ -350,29 +349,63 @@ export class ${this.SubModuleBaseName}${moduleNumber} { }
350
349
  });
351
350
  }
352
351
  });
353
- // nowe our libs array is good to go, we can generate the import statements for the libraries and the items within the libraries
352
+ // now our libs array is good to go, we can generate the import statements for the libraries and the items within the libraries
354
353
  const generationImports = distinctLibs.map(l => `import { ${l.items.join(", ")} } from "${l.lib}"`).join('\n');
355
354
  const generationInjectedCode = relatedEntitySections.length > 0 ?
356
355
  relatedEntitySections.filter(s => s.GeneratedOutput && s.GeneratedOutput.CodeOutput.length > 0)
357
356
  .map(s => s.GeneratedOutput.CodeOutput.split("\n").map(l => ` ${l}`).join("\n")).join('\n') : '';
357
+ // Generate unique keys for all sections FIRST, then use them everywhere
358
+ const sectionsWithoutTop = additionalSections.filter(s => s.Type !== core_1.GeneratedFormSectionType.Top && s.Name);
359
+ const allSections = [...sectionsWithoutTop, ...relatedEntitySections];
360
+ // Assign unique keys to each section
361
+ const usedKeys = new Set();
362
+ allSections.forEach((s) => {
363
+ let sectionKey = this.camelCase(s.Name);
364
+ // Ensure unique keys by tracking used keys and adding suffix for duplicates
365
+ let suffix = 1;
366
+ while (usedKeys.has(sectionKey)) {
367
+ sectionKey = this.camelCase(s.Name) + suffix++;
368
+ }
369
+ usedKeys.add(sectionKey);
370
+ s.UniqueKey = sectionKey; // Store the unique key with the section
371
+ });
372
+ // Now update all TabCode with the correct unique keys
373
+ allSections.forEach(s => {
374
+ if (s.TabCode && s.UniqueKey) {
375
+ // Replace placeholder camelCase keys with actual unique keys in the HTML
376
+ const placeholderKey = this.camelCase(s.Name);
377
+ if (placeholderKey !== s.UniqueKey) {
378
+ // Only replace if they're different (i.e., there was a duplicate)
379
+ const keyRegex = new RegExp(placeholderKey, 'g');
380
+ s.TabCode = s.TabCode.replace(keyRegex, s.UniqueKey);
381
+ }
382
+ }
383
+ });
384
+ // Generate plain objects for section initialization
385
+ const sectionInitEntries = allSections.map((s, index) => {
386
+ // First 2 sections expanded by default, metadata and related entities collapsed
387
+ const isExpanded = index < 2 && !s.Name.toLowerCase().includes('metadata') && !s.IsRelatedEntity;
388
+ return ` { sectionKey: '${s.UniqueKey}', sectionName: '${s.Name}', isExpanded: ${isExpanded} }`;
389
+ });
390
+ const sectionInitCode = sectionInitEntries.length > 0
391
+ ? `\n\n override async ngOnInit() {\n await super.ngOnInit();\n this.initSections([\n${sectionInitEntries.join(',\n')}\n ]);\n }`
392
+ : '';
358
393
  return `import { Component } from '@angular/core';
359
394
  import { ${entityObjectClass}Entity } from '${entity.SchemaName === config_1.mjCoreSchema ? '@memberjunction/core-entities' : 'mj_generatedentities'}';
360
395
  import { RegisterClass } from '@memberjunction/global';
361
396
  import { BaseFormComponent } from '@memberjunction/ng-base-forms';
362
- ${sectionImports}${generationImports.length > 0 ? '\n' + generationImports : ''}
363
-
397
+ ${generationImports.length > 0 ? generationImports + '\n' : ''}
364
398
  @RegisterClass(BaseFormComponent, '${entity.Name}') // Tell MemberJunction about this class
365
399
  @Component({
366
400
  selector: 'gen-${entity.ClassName.toLowerCase()}-form',
367
- templateUrl: './${entity.ClassName.toLowerCase()}.form.component.html',
368
- styleUrls: ['../../../../shared/form-styles.css']
401
+ templateUrl: './${entity.ClassName.toLowerCase()}.form.component.html'
369
402
  })
370
403
  export class ${entity.ClassName}FormComponent extends BaseFormComponent {
371
- public record!: ${entityObjectClass}Entity;${generationInjectedCode.length > 0 ? '\n' + generationInjectedCode : ''}
372
- }
404
+ public record!: ${entityObjectClass}Entity;${generationInjectedCode.length > 0 ? '\n' + generationInjectedCode : ''}${sectionInitCode}
405
+ }
373
406
 
374
407
  export function Load${entity.ClassName}FormComponent() {
375
- ${additionalSections.map(s => `Load${s.ClassName}();`).join('\n ')}
408
+ // does nothing, but called to prevent tree-shaking from eliminating this component from the build
376
409
  }
377
410
  `;
378
411
  }
@@ -401,44 +434,71 @@ export function Load${entity.ClassName}FormComponent() {
401
434
  * @param sections Array of existing sections
402
435
  * @param type The type of section to add
403
436
  * @param name The name of the section
437
+ * @param fieldSequence Optional sequence number of the field (used to track minimum sequence for sorting)
404
438
  */
405
- AddSectionIfNeeded(entity, sections, type, name) {
439
+ AddSectionIfNeeded(entity, sections, type, name, fieldSequence) {
406
440
  const section = sections.find(s => s.Name === name && s.Type === type);
407
- const fName = `${this.stripWhiteSpace(name.toLowerCase())}.component`;
408
- if (!section)
441
+ const fName = `${this.sanitizeFilename(name)}.component`;
442
+ if (!section) {
409
443
  sections.push({
410
444
  Type: type,
411
445
  Name: name,
412
446
  FileName: `${fName}.ts`,
413
447
  ComponentCode: '',
414
- ClassName: `${entity.ClassName}${this.stripWhiteSpace(name)}Component`,
448
+ ClassName: `${entity.ClassName}${this.pascalCase(name)}Component`,
415
449
  TabCode: '',
416
450
  Fields: [],
417
451
  EntityClassName: entity.ClassName,
418
- FileNameWithoutExtension: fName
452
+ FileNameWithoutExtension: fName,
453
+ MinSequence: fieldSequence
419
454
  });
455
+ }
456
+ else if (fieldSequence != null && (section.MinSequence == null || fieldSequence < section.MinSequence)) {
457
+ // Update the minimum sequence if this field has a lower sequence
458
+ section.MinSequence = fieldSequence;
459
+ }
420
460
  }
421
461
  /**
422
462
  * Generates additional form sections based on entity field metadata
423
463
  * @param entity The entity to generate sections for
424
464
  * @param startIndex Starting index for tab ordering
465
+ * @param categoryIcons Optional map of category names to Font Awesome icon classes
425
466
  * @returns Array of generated form sections
426
467
  */
427
- generateAngularAdditionalSections(entity, startIndex) {
468
+ generateAngularAdditionalSections(entity, startIndex, categoryIcons) {
428
469
  const sections = [];
429
470
  let index = startIndex;
430
471
  const sortedFields = (0, util_1.sortBySequenceAndCreatedAt)(entity.Fields);
431
472
  for (const field of sortedFields) {
432
473
  if (field.IncludeInGeneratedForm) {
433
474
  if (field.GeneratedFormSectionType === core_1.GeneratedFormSectionType.Category && field.Category && field.Category !== '' && field.IncludeInGeneratedForm)
434
- this.AddSectionIfNeeded(entity, sections, core_1.GeneratedFormSectionType.Category, field.Category);
475
+ this.AddSectionIfNeeded(entity, sections, core_1.GeneratedFormSectionType.Category, field.Category, field.Sequence);
435
476
  else if (field.GeneratedFormSectionType === core_1.GeneratedFormSectionType.Details)
436
- this.AddSectionIfNeeded(entity, sections, core_1.GeneratedFormSectionType.Details, "Details");
477
+ this.AddSectionIfNeeded(entity, sections, core_1.GeneratedFormSectionType.Details, "Details", field.Sequence);
437
478
  else if (field.GeneratedFormSectionType === core_1.GeneratedFormSectionType.Top)
438
- this.AddSectionIfNeeded(entity, sections, core_1.GeneratedFormSectionType.Top, "Top");
479
+ this.AddSectionIfNeeded(entity, sections, core_1.GeneratedFormSectionType.Top, "Top", field.Sequence);
439
480
  }
440
481
  }
441
- // now we have a distinct list of section names set, generate HTML for each section
482
+ // Sort sections by minimum sequence (Top sections first, System sections last, then by MinSequence)
483
+ sections.sort((a, b) => {
484
+ // Top sections always first
485
+ if (a.Type === core_1.GeneratedFormSectionType.Top)
486
+ return -1;
487
+ if (b.Type === core_1.GeneratedFormSectionType.Top)
488
+ return 1;
489
+ // System sections always last (after related entities)
490
+ const aIsSystem = a.Name.toLowerCase() === 'system' || a.Name.toLowerCase() === 'system metadata';
491
+ const bIsSystem = b.Name.toLowerCase() === 'system' || b.Name.toLowerCase() === 'system metadata';
492
+ if (aIsSystem && !bIsSystem)
493
+ return 1;
494
+ if (!aIsSystem && bIsSystem)
495
+ return -1;
496
+ // Otherwise sort by sequence
497
+ const aSeq = a.MinSequence ?? Number.MAX_SAFE_INTEGER;
498
+ const bSeq = b.MinSequence ?? Number.MAX_SAFE_INTEGER;
499
+ return aSeq - bSeq;
500
+ });
501
+ // now we have a distinct list of section names set, generate HTML for each section
442
502
  let sectionIndex = 0;
443
503
  for (const section of sections) {
444
504
  let sectionName = '';
@@ -451,44 +511,34 @@ export function Load${entity.ClassName}FormComponent() {
451
511
  sectionName = this.stripWhiteSpace(section.Name.toLowerCase());
452
512
  else if (section.Type === core_1.GeneratedFormSectionType.Details)
453
513
  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>`;
514
+ // Generate collapsible panel HTML inline instead of using separate components
515
+ const formHTML = this.generateSectionHTMLForAngular(entity, section);
516
+ // Use category-specific icon from LLM if available, otherwise fall back to keyword matching
517
+ const icon = (categoryIcons && categoryIcons[section.Name]) || this.getIconForCategory(section.Name);
518
+ // NOTE: We'll set the UniqueKey later in generateSingleEntityTypeScriptForAngular()
519
+ // For now, just use a placeholder that will be replaced
520
+ const sectionKey = this.camelCase(section.Name);
521
+ // Build field names string for search functionality (includes both CodeName and DisplayName)
522
+ const fieldSearchTerms = section.Fields ? section.Fields.map(f => {
523
+ const terms = [f.CodeName.toLowerCase()];
524
+ if (f.DisplayName && f.DisplayName.toLowerCase() !== f.CodeName.toLowerCase()) {
525
+ terms.push(f.DisplayName.toLowerCase());
526
+ }
527
+ return terms.join(' ');
528
+ }).join(' ') : '';
529
+ // No additional indentation needed - formHTML is already properly indented
530
+ const indentedFormHTML = formHTML;
531
+ section.TabCode = `${sectionIndex > 0 ? '\n' : ''} <!-- ${section.Name} Section -->
532
+ <mj-collapsible-panel slot="field-panels"
533
+ sectionKey="${sectionKey}"
534
+ sectionName="${section.Name}"
535
+ icon="${icon}"
536
+ [form]="this"
537
+ [formContext]="formContext">
538
+ ${indentedFormHTML}
539
+ </mj-collapsible-panel>`;
540
+ sectionIndex++;
465
541
  }
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">
478
- ${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
- `;
492
542
  if (section.Type !== core_1.GeneratedFormSectionType.Top)
493
543
  index++; // don't increment the tab index for TOP AREA, becuse it won't be rendered as a tab
494
544
  }
@@ -583,7 +633,8 @@ export function Load${entity.ClassName}${this.stripWhiteSpace(section.Name)}Comp
583
633
  [ShowLabel]="${section.Fields.length > 1 ? 'true' : 'false'}"
584
634
  FieldName="${field.CodeName}"
585
635
  Type="${editControl}"
586
- [EditMode]="EditMode"${linkType ? `\n LinkType="${linkType}"` : ''}${linkComponentType ? linkComponentType : ''}
636
+ [EditMode]="EditMode"
637
+ [formContext]="formContext"${linkType ? `\n LinkType="${linkType}"` : ''}${linkComponentType ? linkComponentType : ''}
587
638
  ></mj-form-field>
588
639
  `;
589
640
  }
@@ -632,39 +683,65 @@ export function Load${entity.ClassName}${this.stripWhiteSpace(section.Name)}Comp
632
683
  async generateRelatedEntityTabs(entity, startIndex, contextUser) {
633
684
  const md = new core_1.Metadata();
634
685
  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
686
+ // Sort related entities by Sequence (user's explicit ordering), then by RelatedEntity name (stable tiebreaker)
687
+ const sortedRelatedEntities = entity.RelatedEntities
688
+ .filter(re => re.DisplayInForm)
689
+ .sort((a, b) => {
690
+ if (a.Sequence !== b.Sequence) {
691
+ return a.Sequence - b.Sequence;
692
+ }
693
+ return a.RelatedEntity.localeCompare(b.RelatedEntity);
694
+ });
636
695
  let index = startIndex;
637
696
  for (const relatedEntity of sortedRelatedEntities) {
638
697
  const tabName = this.generateRelatedEntityTabName(relatedEntity, sortedRelatedEntities);
639
698
  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;
699
+ let iconClass = '';
700
+ // First, check for custom icon
701
+ if (relatedEntity.DisplayIconType === 'Custom' && relatedEntity.DisplayIcon && relatedEntity.DisplayIcon.length > 0) {
702
+ icon = `<span class="${relatedEntity.DisplayIcon} tab-header-icon"></span>`;
703
+ iconClass = relatedEntity.DisplayIcon;
653
704
  }
705
+ // If no custom icon, try to use the related entity's icon
706
+ else {
707
+ const re = md.Entities.find(e => e.ID === relatedEntity.RelatedEntityID);
708
+ if (re && re.Icon && re.Icon.length > 0) {
709
+ icon = `<span class="${re.Icon} tab-header-icon"></span>`;
710
+ iconClass = re.Icon;
711
+ }
712
+ else {
713
+ // Fall back to default table icon
714
+ iconClass = 'fa fa-table';
715
+ }
716
+ }
717
+ // Calculate section key before generation (may be replaced later if duplicate)
718
+ const sectionKey = this.camelCase(tabName);
654
719
  const component = await related_entity_components_1.RelatedEntityDisplayComponentGeneratorBase.GetComponent(relatedEntity, contextUser);
655
720
  const generateResults = await component.Generate({
656
721
  Entity: entity,
657
722
  RelationshipInfo: relatedEntity,
658
- TabName: tabName
723
+ TabName: tabName,
724
+ SectionKey: sectionKey // Pass section key for IsSectionExpanded() calls
659
725
  });
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>`;
726
+ // Add proper indentation for collapsible panel body (12 spaces for div content)
727
+ const componentCodeWithIndent = generateResults.TemplateOutput.split('\n').map(l => ` ${l}`).join('\n');
728
+ // For related entities, use the related entity name as searchable term
729
+ const relatedEntitySearchTerms = relatedEntity.RelatedEntity.toLowerCase();
730
+ // Determine slot based on DisplayLocation
731
+ const slot = relatedEntity.DisplayLocation === 'Before Field Tabs' ? 'before-panels' : 'after-panels';
732
+ const tabCode = `${index > 0 ? '\n' : ''} <!-- ${tabName} Section -->
733
+ <mj-collapsible-panel slot="${slot}"
734
+ sectionKey="${sectionKey}"
735
+ sectionName="${tabName}"
736
+ icon="${iconClass}"
737
+ variant="related-entity"
738
+ [form]="this"
739
+ [formContext]="formContext"
740
+ [badgeCount]="GetSectionRowCount('${sectionKey}')">
741
+ <div *ngIf="record.IsSaved">
742
+ ${componentCodeWithIndent}
743
+ </div>
744
+ </mj-collapsible-panel>`;
668
745
  tabs.push({
669
746
  Type: core_1.GeneratedFormSectionType.Category,
670
747
  IsRelatedEntity: true,
@@ -686,6 +763,129 @@ ${componentCodeWithTabs}
686
763
  stripWhiteSpace(s) {
687
764
  return s.replace(/\s/g, '');
688
765
  }
766
+ /**
767
+ * Converts a string to camelCase and sanitizes it for use as a JavaScript identifier
768
+ * @param str The string to convert
769
+ * @returns String in camelCase format, safe for use as object key or variable name
770
+ */
771
+ camelCase(str) {
772
+ // First, replace non-alphanumeric characters (except spaces) with spaces
773
+ let sanitized = str.replace(/[^a-zA-Z0-9\s]/g, ' ');
774
+ // Convert to camelCase
775
+ let result = sanitized
776
+ .replace(/\s(.)/g, (match, char) => char.toUpperCase())
777
+ .replace(/\s/g, '')
778
+ .replace(/^(.)/, (match, char) => char.toLowerCase());
779
+ // If starts with a digit, prefix with underscore
780
+ if (/^\d/.test(result)) {
781
+ result = '_' + result;
782
+ }
783
+ // If result is empty (all special chars), use a default
784
+ if (result.length === 0) {
785
+ result = 'section';
786
+ }
787
+ return result;
788
+ }
789
+ /**
790
+ * Converts a string to PascalCase and sanitizes it for use as a class name
791
+ * @param str The string to convert
792
+ * @returns String in PascalCase format, safe for use as a class name
793
+ */
794
+ pascalCase(str) {
795
+ // First, replace non-alphanumeric characters (except spaces) with spaces
796
+ let sanitized = str.replace(/[^a-zA-Z0-9\s]/g, ' ');
797
+ // Convert to PascalCase (capitalize first letter of each word)
798
+ let result = sanitized
799
+ .replace(/\s(.)/g, (match, char) => char.toUpperCase())
800
+ .replace(/\s/g, '')
801
+ .replace(/^(.)/, (match, char) => char.toUpperCase());
802
+ // If starts with a digit, prefix with underscore
803
+ if (/^\d/.test(result)) {
804
+ result = '_' + result;
805
+ }
806
+ // If result is empty (all special chars), use a default
807
+ if (result.length === 0) {
808
+ result = 'Section';
809
+ }
810
+ return result;
811
+ }
812
+ /**
813
+ * Sanitizes a string to create a valid filename in lowercase format.
814
+ * Removes all non-alphanumeric characters (except spaces) and converts to lowercase.
815
+ * Used for creating component filenames that are safe across all file systems.
816
+ *
817
+ * Example: "Timeline & Budget" → "timelinebudget"
818
+ *
819
+ * @param str The string to sanitize
820
+ * @returns A sanitized lowercase filename string
821
+ */
822
+ sanitizeFilename(str) {
823
+ // Remove all non-alphanumeric characters (except spaces)
824
+ let sanitized = str.replace(/[^a-zA-Z0-9\s]/g, '');
825
+ // Convert to lowercase and remove all spaces
826
+ let result = sanitized.toLowerCase().replace(/\s/g, '');
827
+ // If result is empty (all special chars), use a default
828
+ if (result.length === 0) {
829
+ result = 'section';
830
+ }
831
+ return result;
832
+ }
833
+ /**
834
+ * Maps category names to appropriate Font Awesome icon classes
835
+ * @param category The category name to map
836
+ * @returns Font Awesome icon class string
837
+ */
838
+ getIconForCategory(category) {
839
+ const lowerCategory = category.toLowerCase();
840
+ // Address/Location categories
841
+ if (lowerCategory.includes('address') || lowerCategory.includes('location')) {
842
+ return 'fa fa-map-marker-alt';
843
+ }
844
+ // Contact Information
845
+ if (lowerCategory.includes('contact')) {
846
+ return 'fa fa-address-card';
847
+ }
848
+ // Financial/Pricing categories
849
+ if (lowerCategory.includes('pric') || lowerCategory.includes('cost') ||
850
+ lowerCategory.includes('payment') || lowerCategory.includes('financial') ||
851
+ lowerCategory.includes('billing')) {
852
+ return 'fa fa-dollar-sign';
853
+ }
854
+ // Date/Time categories
855
+ if (lowerCategory.includes('date') || lowerCategory.includes('time') ||
856
+ lowerCategory.includes('schedule')) {
857
+ return 'fa fa-calendar';
858
+ }
859
+ // Status/State categories
860
+ if (lowerCategory.includes('status') || lowerCategory.includes('state')) {
861
+ return 'fa fa-flag';
862
+ }
863
+ // Metadata/Technical categories
864
+ if (lowerCategory.includes('metadata') || lowerCategory.includes('technical') ||
865
+ lowerCategory.includes('system')) {
866
+ return 'fa fa-cog';
867
+ }
868
+ // Description/Details categories
869
+ if (lowerCategory.includes('description') || lowerCategory.includes('detail')) {
870
+ return 'fa fa-align-left';
871
+ }
872
+ // Settings/Configuration categories
873
+ if (lowerCategory.includes('setting') || lowerCategory.includes('config') ||
874
+ lowerCategory.includes('preference')) {
875
+ return 'fa fa-sliders-h';
876
+ }
877
+ // Shipping/Delivery categories
878
+ if (lowerCategory.includes('ship') || lowerCategory.includes('delivery')) {
879
+ return 'fa fa-truck';
880
+ }
881
+ // User/Person categories
882
+ if (lowerCategory.includes('user') || lowerCategory.includes('person') ||
883
+ lowerCategory.includes('customer') || lowerCategory.includes('employee')) {
884
+ return 'fa fa-user';
885
+ }
886
+ // Default icon for uncategorized sections
887
+ return 'fa fa-info-circle';
888
+ }
689
889
  /**
690
890
  * Generates the complete HTML template for a single entity form
691
891
  * @param entity The entity to generate HTML for
@@ -693,8 +893,22 @@ ${componentCodeWithTabs}
693
893
  * @returns Promise resolving to an object containing the HTML code and section information
694
894
  */
695
895
  async generateSingleEntityHTMLForAngular(entity, contextUser) {
896
+ // Load category icons from EntitySetting if available
897
+ let categoryIcons;
898
+ const entitySettings = entity.Settings;
899
+ if (entitySettings) {
900
+ const iconSetting = entitySettings.find((s) => s.Name === 'FieldCategoryIcons');
901
+ if (iconSetting && iconSetting.Value) {
902
+ try {
903
+ categoryIcons = JSON.parse(iconSetting.Value);
904
+ }
905
+ catch (e) {
906
+ // Invalid JSON, ignore and fall back to keyword matching
907
+ }
908
+ }
909
+ }
696
910
  const topArea = this.generateTopAreaHTMLForAngular(entity);
697
- const additionalSections = this.generateAngularAdditionalSections(entity, 0);
911
+ const additionalSections = this.generateAngularAdditionalSections(entity, 0, categoryIcons);
698
912
  // 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
913
  const endingIndex = additionalSections && additionalSections.length ? (topArea && topArea.length > 0 ? additionalSections.length - 1 : additionalSections.length) : 0;
700
914
  const relatedEntitySections = await this.generateRelatedEntityTabs(entity, endingIndex, contextUser);
@@ -710,19 +924,16 @@ ${componentCodeWithTabs}
710
924
  * @returns Generated HTML with splitter layout
711
925
  */
712
926
  generateSingleEntityHTMLWithSplitterForAngular(topArea, additionalSections, relatedEntitySections) {
713
- const htmlCode = `<div class="record-form-container" >
714
- <form *ngIf="record" class="record-form" #form="ngForm" >
715
- <mj-form-toolbar [form]="this"></mj-form-toolbar>
716
- <kendo-splitter orientation="vertical" (layoutChange)="splitterLayoutChange()" >
717
- <kendo-splitter-pane [collapsible]="true" [size]="TopAreaHeight">
927
+ const htmlCode = `<mj-record-form-container [record]="record" [formComponent]="this">
928
+ <kendo-splitter orientation="vertical" (layoutChange)="splitterLayoutChange()">
929
+ <kendo-splitter-pane [collapsible]="true" [size]="TopAreaHeight">
718
930
  ${this.innerTopAreaHTML(topArea)}
719
- </kendo-splitter-pane>
720
- <kendo-splitter-pane>
721
- ${this.innerTabStripHTML(additionalSections, relatedEntitySections)}
722
- </kendo-splitter-pane>
723
- </kendo-splitter>
724
- </form>
725
- </div>
931
+ </kendo-splitter-pane>
932
+ <kendo-splitter-pane>
933
+ ${this.innerCollapsiblePanelsHTML(additionalSections, relatedEntitySections)}
934
+ </kendo-splitter-pane>
935
+ </kendo-splitter>
936
+ </mj-record-form-container>
726
937
  `;
727
938
  return htmlCode;
728
939
  }
@@ -740,6 +951,44 @@ ${this.innerTabStripHTML(additionalSections, relatedEntitySections)}
740
951
  </div>`;
741
952
  }
742
953
  /**
954
+ * Generates the HTML for collapsible panels containing all form sections
955
+ * @param additionalSections Array of field-based form sections
956
+ * @param relatedEntitySections Array of related entity sections
957
+ * @returns HTML string for all collapsible panels
958
+ */
959
+ innerCollapsiblePanelsHTML(additionalSections, relatedEntitySections) {
960
+ // Filter out Top sections as they're handled separately
961
+ const sectionsToRender = additionalSections.filter(s => s.Type !== core_1.GeneratedFormSectionType.Top);
962
+ // Order: before-panels, field-panels, after-panels
963
+ // The RecordFormContainer handles the related-entity-grid wrapper via named slots
964
+ const beforePanels = relatedEntitySections.filter(s => s.RelatedEntityDisplayLocation === 'Before Field Tabs');
965
+ const afterPanels = relatedEntitySections.filter(s => s.RelatedEntityDisplayLocation === 'After Field Tabs');
966
+ const parts = [];
967
+ // Add before panels if any
968
+ if (beforePanels.length > 0) {
969
+ parts.push(' <!-- ========================================');
970
+ parts.push(' RELATED ENTITY PANELS - BEFORE');
971
+ parts.push(' ======================================== -->');
972
+ parts.push(beforePanels.map(s => s.TabCode).join('\n'));
973
+ }
974
+ // Add field panels with header comment
975
+ if (sectionsToRender.length > 0) {
976
+ parts.push(' <!-- ========================================');
977
+ parts.push(' FIELD PANELS');
978
+ parts.push(' ======================================== -->');
979
+ parts.push(sectionsToRender.map(s => s.TabCode).join('\n'));
980
+ }
981
+ // Add after panels if any
982
+ if (afterPanels.length > 0) {
983
+ parts.push(' <!-- ========================================');
984
+ parts.push(' RELATED ENTITY PANELS - AFTER');
985
+ parts.push(' ======================================== -->');
986
+ parts.push(afterPanels.map(s => s.TabCode).join('\n'));
987
+ }
988
+ return parts.join('\n');
989
+ }
990
+ /**
991
+ * @deprecated Use innerCollapsiblePanelsHTML instead
743
992
  * Generates the HTML for the tab strip containing all form sections
744
993
  * @param additionalSections Array of field-based form sections
745
994
  * @param relatedEntitySections Array of related entity sections
@@ -764,13 +1013,10 @@ ${this.innerTabStripHTML(additionalSections, relatedEntitySections)}
764
1013
  * @returns Generated HTML without splitter layout
765
1014
  */
766
1015
  generateSingleEntityHTMLWithOUTSplitterForAngular(topArea, additionalSections, relatedEntitySections) {
767
- const htmlCode = `<div class="record-form-container" >
768
- <form *ngIf="record" class="record-form" #form="ngForm" >
769
- <mj-form-toolbar [form]="this"></mj-form-toolbar>
1016
+ const htmlCode = `<mj-record-form-container [record]="record" [formComponent]="this">
770
1017
  ${this.innerTopAreaHTML(topArea)}
771
- ${this.innerTabStripHTML(additionalSections, relatedEntitySections)}
772
- </form>
773
- </div>
1018
+ ${this.innerCollapsiblePanelsHTML(additionalSections, relatedEntitySections)}
1019
+ </mj-record-form-container>
774
1020
  `;
775
1021
  return htmlCode;
776
1022
  }