@carlonicora/nextjs-jsonapi 1.110.0 → 1.111.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 (123) hide show
  1. package/dist/scripts/generate-web-module/__tests__/container.spec.d.ts +2 -0
  2. package/dist/scripts/generate-web-module/__tests__/container.spec.d.ts.map +1 -0
  3. package/dist/scripts/generate-web-module/__tests__/container.spec.js +54 -0
  4. package/dist/scripts/generate-web-module/__tests__/container.spec.js.map +1 -0
  5. package/dist/scripts/generate-web-module/__tests__/data-service.spec.d.ts +2 -0
  6. package/dist/scripts/generate-web-module/__tests__/data-service.spec.d.ts.map +1 -0
  7. package/dist/scripts/generate-web-module/__tests__/data-service.spec.js +19 -0
  8. package/dist/scripts/generate-web-module/__tests__/data-service.spec.js.map +1 -0
  9. package/dist/scripts/generate-web-module/__tests__/editor.spec.d.ts +2 -0
  10. package/dist/scripts/generate-web-module/__tests__/editor.spec.d.ts.map +1 -0
  11. package/dist/scripts/generate-web-module/__tests__/editor.spec.js +35 -0
  12. package/dist/scripts/generate-web-module/__tests__/editor.spec.js.map +1 -0
  13. package/dist/scripts/generate-web-module/__tests__/fixtures.d.ts +4 -0
  14. package/dist/scripts/generate-web-module/__tests__/fixtures.d.ts.map +1 -0
  15. package/dist/scripts/generate-web-module/__tests__/fixtures.js +45 -0
  16. package/dist/scripts/generate-web-module/__tests__/fixtures.js.map +1 -0
  17. package/dist/scripts/generate-web-module/__tests__/model-interface.spec.d.ts +2 -0
  18. package/dist/scripts/generate-web-module/__tests__/model-interface.spec.d.ts.map +1 -0
  19. package/dist/scripts/generate-web-module/__tests__/model-interface.spec.js +74 -0
  20. package/dist/scripts/generate-web-module/__tests__/model-interface.spec.js.map +1 -0
  21. package/dist/scripts/generate-web-module/__tests__/plumbing.spec.d.ts +2 -0
  22. package/dist/scripts/generate-web-module/__tests__/plumbing.spec.d.ts.map +1 -0
  23. package/dist/scripts/generate-web-module/__tests__/plumbing.spec.js +21 -0
  24. package/dist/scripts/generate-web-module/__tests__/plumbing.spec.js.map +1 -0
  25. package/dist/scripts/generate-web-module/__tests__/selector.spec.d.ts +2 -0
  26. package/dist/scripts/generate-web-module/__tests__/selector.spec.d.ts.map +1 -0
  27. package/dist/scripts/generate-web-module/__tests__/selector.spec.js +25 -0
  28. package/dist/scripts/generate-web-module/__tests__/selector.spec.js.map +1 -0
  29. package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.d.ts +2 -0
  30. package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.d.ts.map +1 -0
  31. package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.js +55 -0
  32. package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.js.map +1 -0
  33. package/dist/scripts/generate-web-module/generator.d.ts.map +1 -1
  34. package/dist/scripts/generate-web-module/generator.js +14 -11
  35. package/dist/scripts/generate-web-module/generator.js.map +1 -1
  36. package/dist/scripts/generate-web-module/templates/components/container.template.d.ts +5 -1
  37. package/dist/scripts/generate-web-module/templates/components/container.template.d.ts.map +1 -1
  38. package/dist/scripts/generate-web-module/templates/components/container.template.js +43 -63
  39. package/dist/scripts/generate-web-module/templates/components/container.template.js.map +1 -1
  40. package/dist/scripts/generate-web-module/templates/components/content.template.d.ts +7 -4
  41. package/dist/scripts/generate-web-module/templates/components/content.template.d.ts.map +1 -1
  42. package/dist/scripts/generate-web-module/templates/components/content.template.js +103 -14
  43. package/dist/scripts/generate-web-module/templates/components/content.template.js.map +1 -1
  44. package/dist/scripts/generate-web-module/templates/components/editor.template.d.ts +1 -1
  45. package/dist/scripts/generate-web-module/templates/components/editor.template.js +139 -150
  46. package/dist/scripts/generate-web-module/templates/components/editor.template.js.map +1 -1
  47. package/dist/scripts/generate-web-module/templates/components/index.d.ts +0 -1
  48. package/dist/scripts/generate-web-module/templates/components/index.d.ts.map +1 -1
  49. package/dist/scripts/generate-web-module/templates/components/index.js +1 -3
  50. package/dist/scripts/generate-web-module/templates/components/index.js.map +1 -1
  51. package/dist/scripts/generate-web-module/templates/components/list-container.template.js +2 -2
  52. package/dist/scripts/generate-web-module/templates/components/list.template.d.ts.map +1 -1
  53. package/dist/scripts/generate-web-module/templates/components/list.template.js +6 -1
  54. package/dist/scripts/generate-web-module/templates/components/list.template.js.map +1 -1
  55. package/dist/scripts/generate-web-module/templates/components/selector.template.d.ts.map +1 -1
  56. package/dist/scripts/generate-web-module/templates/components/selector.template.js +48 -52
  57. package/dist/scripts/generate-web-module/templates/components/selector.template.js.map +1 -1
  58. package/dist/scripts/generate-web-module/templates/data/interface.template.d.ts.map +1 -1
  59. package/dist/scripts/generate-web-module/templates/data/interface.template.js +38 -11
  60. package/dist/scripts/generate-web-module/templates/data/interface.template.js.map +1 -1
  61. package/dist/scripts/generate-web-module/templates/data/model.template.d.ts.map +1 -1
  62. package/dist/scripts/generate-web-module/templates/data/model.template.js +83 -26
  63. package/dist/scripts/generate-web-module/templates/data/model.template.js.map +1 -1
  64. package/dist/scripts/generate-web-module/templates/data/service.template.d.ts.map +1 -1
  65. package/dist/scripts/generate-web-module/templates/data/service.template.js +21 -1
  66. package/dist/scripts/generate-web-module/templates/data/service.template.js.map +1 -1
  67. package/dist/scripts/generate-web-module/templates/index.d.ts +1 -1
  68. package/dist/scripts/generate-web-module/templates/index.d.ts.map +1 -1
  69. package/dist/scripts/generate-web-module/templates/index.js +1 -2
  70. package/dist/scripts/generate-web-module/templates/index.js.map +1 -1
  71. package/dist/scripts/generate-web-module/templates/module.template.d.ts.map +1 -1
  72. package/dist/scripts/generate-web-module/templates/module.template.js +11 -3
  73. package/dist/scripts/generate-web-module/templates/module.template.js.map +1 -1
  74. package/dist/scripts/generate-web-module/templates/table-hook.template.js +31 -1
  75. package/dist/scripts/generate-web-module/templates/table-hook.template.js.map +1 -1
  76. package/dist/scripts/generate-web-module/transformers/import-resolver.d.ts +0 -1
  77. package/dist/scripts/generate-web-module/transformers/import-resolver.d.ts.map +1 -1
  78. package/dist/scripts/generate-web-module/transformers/import-resolver.js +0 -1
  79. package/dist/scripts/generate-web-module/transformers/import-resolver.js.map +1 -1
  80. package/dist/scripts/generate-web-module/transformers/relationship-key.d.ts +5 -0
  81. package/dist/scripts/generate-web-module/transformers/relationship-key.d.ts.map +1 -0
  82. package/dist/scripts/generate-web-module/transformers/relationship-key.js +21 -0
  83. package/dist/scripts/generate-web-module/transformers/relationship-key.js.map +1 -0
  84. package/dist/scripts/generate-web-module/transformers/relationship-resolver.d.ts.map +1 -1
  85. package/dist/scripts/generate-web-module/transformers/relationship-resolver.js +2 -0
  86. package/dist/scripts/generate-web-module/transformers/relationship-resolver.js.map +1 -1
  87. package/dist/scripts/generate-web-module/types/json-schema.interface.d.ts +21 -0
  88. package/dist/scripts/generate-web-module/types/json-schema.interface.d.ts.map +1 -1
  89. package/dist/scripts/generate-web-module/types/template-data.interface.d.ts +17 -0
  90. package/dist/scripts/generate-web-module/types/template-data.interface.d.ts.map +1 -1
  91. package/package.json +1 -1
  92. package/scripts/generate-web-module/__tests__/container.spec.ts +58 -0
  93. package/scripts/generate-web-module/__tests__/data-service.spec.ts +18 -0
  94. package/scripts/generate-web-module/__tests__/editor.spec.ts +36 -0
  95. package/scripts/generate-web-module/__tests__/fixtures.ts +43 -0
  96. package/scripts/generate-web-module/__tests__/model-interface.spec.ts +77 -0
  97. package/scripts/generate-web-module/__tests__/plumbing.spec.ts +21 -0
  98. package/scripts/generate-web-module/__tests__/selector.spec.ts +24 -0
  99. package/scripts/generate-web-module/__tests__/table-module-list.spec.ts +59 -0
  100. package/scripts/generate-web-module/generator.ts +14 -13
  101. package/scripts/generate-web-module/templates/components/container.template.ts +51 -66
  102. package/scripts/generate-web-module/templates/components/content.template.ts +107 -15
  103. package/scripts/generate-web-module/templates/components/editor.template.ts +140 -152
  104. package/scripts/generate-web-module/templates/components/index.ts +0 -1
  105. package/scripts/generate-web-module/templates/components/list-container.template.ts +2 -2
  106. package/scripts/generate-web-module/templates/components/list.template.ts +6 -1
  107. package/scripts/generate-web-module/templates/components/selector.template.ts +48 -52
  108. package/scripts/generate-web-module/templates/data/interface.template.ts +44 -12
  109. package/scripts/generate-web-module/templates/data/model.template.ts +88 -28
  110. package/scripts/generate-web-module/templates/data/service.template.ts +23 -1
  111. package/scripts/generate-web-module/templates/index.ts +0 -1
  112. package/scripts/generate-web-module/templates/module.template.ts +13 -3
  113. package/scripts/generate-web-module/templates/table-hook.template.ts +31 -2
  114. package/scripts/generate-web-module/transformers/import-resolver.ts +0 -2
  115. package/scripts/generate-web-module/transformers/relationship-key.ts +18 -0
  116. package/scripts/generate-web-module/transformers/relationship-resolver.ts +2 -0
  117. package/scripts/generate-web-module/types/json-schema.interface.ts +9 -0
  118. package/scripts/generate-web-module/types/template-data.interface.ts +7 -0
  119. package/dist/scripts/generate-web-module/templates/components/details.template.d.ts +0 -14
  120. package/dist/scripts/generate-web-module/templates/components/details.template.d.ts.map +0 -1
  121. package/dist/scripts/generate-web-module/templates/components/details.template.js +0 -112
  122. package/dist/scripts/generate-web-module/templates/components/details.template.js.map +0 -1
  123. package/scripts/generate-web-module/templates/components/details.template.ts +0 -116
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { makeFrontendData } from "./fixtures";
3
+ import { generateContainerTemplate } from "../templates/components/container.template";
4
+ import { generateListContainerTemplate } from "../templates/components/list-container.template";
5
+ import { generateContentTemplate } from "../templates/components/content.template";
6
+
7
+ describe("container template", () => {
8
+ it("uses RoundPageContainer with tabs and id, not details prop, and renders Content", () => {
9
+ const out = generateContainerTemplate(makeFrontendData());
10
+ expect(out).toContain("tabs: Tab[]");
11
+ expect(out).toContain("id={widget.id}");
12
+ expect(out).not.toContain("details={");
13
+ expect(out).not.toContain("WidgetDetails");
14
+ expect(out).toContain("<WidgetContent widget={widget} />");
15
+ });
16
+
17
+ it("emits an Activity tab by default and no Tasks tab", () => {
18
+ const out = generateContainerTemplate(makeFrontendData());
19
+ expect(out).toContain("<ActivityFeed module={Modules.Widget} entityId={widget.id} />");
20
+ expect(out).not.toContain("TaskProvider");
21
+ });
22
+
23
+ it("omits the Activity tab when containerTabs.activity is false", () => {
24
+ const out = generateContainerTemplate(makeFrontendData({ containerTabs: { activity: false, relations: [] } }));
25
+ expect(out).not.toContain("ActivityFeed");
26
+ });
27
+
28
+ it("emits a relation tab when containerTabs.relations has an entry", () => {
29
+ const out = generateContainerTemplate(makeFrontendData({
30
+ containerTabs: { activity: true, relations: [{ module: "Quote", listProp: "widget", directory: "sales" }] },
31
+ }));
32
+ expect(out).toContain('import QuoteList from "@/features/sales/quote/components/lists/QuoteList"');
33
+ expect(out).toContain("key: Modules.Quote");
34
+ expect(out).toContain("<QuoteList widget={widget} />");
35
+ });
36
+
37
+ it("gates default export on hasPermissionToModule", () => {
38
+ const out = generateContainerTemplate(makeFrontendData());
39
+ expect(out).toContain("hasPermissionToModule({ module: Modules.Widget, action: Action.Delete, data: widget })");
40
+ });
41
+ });
42
+
43
+ describe("list-container template", () => {
44
+ it("always renders fullWidth", () => {
45
+ const out = generateListContainerTemplate(makeFrontendData());
46
+ expect(out).toContain("<RoundPageContainer module={Modules.Widget} fullWidth>");
47
+ expect(out).toContain("<WidgetList fullWidth />");
48
+ });
49
+ });
50
+
51
+ describe("content template", () => {
52
+ it("is always generated (returns a string) and renders AttributeElement", () => {
53
+ const out = generateContentTemplate(makeFrontendData());
54
+ expect(typeof out).toBe("string");
55
+ expect(out).toContain("export function WidgetContent");
56
+ expect(out).toContain("AttributeElement");
57
+ });
58
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { makeFrontendData } from "./fixtures";
3
+ import { generateServiceTemplate } from "../templates/data/service.template";
4
+
5
+ describe("frontend data service template", () => {
6
+ it("applies inclusions inside findOne", () => {
7
+ const out = generateServiceTemplate(makeFrontendData());
8
+ const findOne = out.split("static async findOne")[1].split("static async")[0];
9
+ expect(findOne).toContain("limitToFields");
10
+ expect(findOne).toContain("limitToType");
11
+ });
12
+
13
+ it("emits a patch method", () => {
14
+ const out = generateServiceTemplate(makeFrontendData());
15
+ expect(out).toContain("static async patch(params: { id: string } & Partial<Omit<WidgetInput, \"id\">>)");
16
+ expect(out).toContain("method: HttpMethod.PATCH");
17
+ });
18
+ });
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { makeFrontendData } from "./fixtures";
3
+ import { generateEditorTemplate } from "../templates/components/editor.template";
4
+
5
+ describe("editor template — EditorSheet", () => {
6
+ it("uses EditorSheet and not the legacy Dialog stack", () => {
7
+ const out = generateEditorTemplate(makeFrontendData());
8
+ expect(out).toContain("EditorSheet");
9
+ expect(out).not.toContain("CommonEditorButtons");
10
+ expect(out).not.toContain("CommonEditorDiscardDialog");
11
+ expect(out).not.toContain("useEditorDialog");
12
+ expect(out).not.toMatch(/DialogContent/);
13
+ });
14
+
15
+ it("gates the default export on hasPermissionToModule", () => {
16
+ const out = generateEditorTemplate(makeFrontendData());
17
+ expect(out).toContain("useCurrentUserContext");
18
+ expect(out).toContain("hasPermissionToModule({ module: Modules.Widget, action, data: props.widget })");
19
+ });
20
+
21
+ it("wraps formSchema in useMemo and defaults in useCallback", () => {
22
+ const out = generateEditorTemplate(makeFrontendData());
23
+ expect(out).toContain("const formSchema = useMemo(");
24
+ expect(out).toContain("const getDefaultValues = useCallback(");
25
+ });
26
+
27
+ it("passes EditorSheet lifecycle props and onSubmit returns the saved entity", () => {
28
+ const out = generateEditorTemplate(makeFrontendData());
29
+ expect(out).toContain("onRevalidate={revalidatePaths}");
30
+ expect(out).toContain("onReset={getDefaultValues}");
31
+ expect(out).toContain("module={Modules.Widget}");
32
+ // onSubmit returns the service result (no manual setOpen/errorToast)
33
+ expect(out).not.toContain("setOpen(false)");
34
+ expect(out).not.toContain("errorToast(");
35
+ });
36
+ });
@@ -0,0 +1,43 @@
1
+ import { FrontendRelationship, FrontendTemplateData } from "../types/template-data.interface";
2
+
3
+ export function makeRelationship(o: Partial<FrontendRelationship> = {}): FrontendRelationship {
4
+ return {
5
+ name: "Account", variant: undefined, alias: undefined, directory: "crm",
6
+ single: true, nullable: false, isFoundation: false,
7
+ formFieldId: "account", formFieldIdPlural: "accounts", payloadFieldId: "accountId",
8
+ selectorComponent: "AccountSelector", zodSchema: "entityObjectSchema",
9
+ importPath: "@/features/crm/account/components/forms/AccountSelector",
10
+ interfaceImportPath: "@/features/crm/account/data/AccountInterface",
11
+ serviceImportPath: "@/features/crm/account/data/AccountService",
12
+ interfaceName: "AccountInterface", modelKebab: "account",
13
+ fields: undefined, targetHasName: true, dtoKey: "", showInTable: false,
14
+ ...o,
15
+ };
16
+ }
17
+
18
+ export function makeFrontendData(o: Partial<FrontendTemplateData> = {}): FrontendTemplateData {
19
+ return {
20
+ names: {
21
+ pascalCase: "Widget", camelCase: "widget", kebabCase: "widget",
22
+ pluralPascal: "Widgets", pluralCamel: "widgets", pluralKebab: "widgets",
23
+ titleCase: "Widget", pluralTitleCase: "Widgets",
24
+ },
25
+ moduleId: "00000000-0000-4000-a000-000000000001",
26
+ endpoint: "widgets", targetDir: "features/demo", importTargetDir: "demo",
27
+ extendsContent: false,
28
+ fields: [
29
+ { name: "name", type: "string", tsType: "string", zodSchema: "z.string()", formComponent: "FormInput", nullable: false, isContentField: false },
30
+ { name: "due_date", type: "date", tsType: "Date", zodSchema: "z.date().optional()", formComponent: "DatePicker", nullable: true, isContentField: false },
31
+ ],
32
+ relationships: [],
33
+ i18nKeys: { moduleName: "widget", fields: {}, relationships: {}, type: { singular: "Widget", plural: "Widgets", icuPlural: "" } },
34
+ imports: { models: [], selectors: [], library: [] },
35
+ tableFieldNames: [],
36
+ relationshipServiceMethods: [],
37
+ featureId: undefined,
38
+ displayProp: "name",
39
+ containerTabs: { activity: true, relations: [] },
40
+ relatedInclusions: [],
41
+ ...o,
42
+ };
43
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { makeFrontendData, makeRelationship } from "./fixtures";
3
+ import { generateModelTemplate } from "../templates/data/model.template";
4
+ import { generateInterfaceTemplate } from "../templates/data/interface.template";
5
+
6
+ describe("model template — dates", () => {
7
+ it("imports formatLocalDate and uses it for date fields in createJsonApi", () => {
8
+ const out = generateModelTemplate(makeFrontendData());
9
+ expect(out).toContain("formatLocalDate");
10
+ expect(out).toContain("response.data.attributes.due_date = formatLocalDate(data.due_date)");
11
+ });
12
+ });
13
+
14
+ describe("model template — relationship wire keys", () => {
15
+ it("uses dtoKey verbatim for rehydrate and createJsonApi", () => {
16
+ const out = generateModelTemplate(makeFrontendData({
17
+ relationships: [makeRelationship({ name: "PipelineStage", single: true, dtoKey: "pipelinestage", interfaceName: "PipelineStageInterface", modelKebab: "pipeline-stage", selectorComponent: "PipelineStageSelector", interfaceImportPath: "@/features/crm/pipeline-stage/data/PipelineStageInterface" })],
18
+ }));
19
+ expect(out).toContain('"pipelinestage"');
20
+ expect(out).toContain("response.data.relationships.pipelinestage");
21
+ });
22
+
23
+ it("emits per-item meta array for many-with-edge-fields", () => {
24
+ const out = generateModelTemplate(makeFrontendData({
25
+ relationships: [makeRelationship({
26
+ name: "Item", single: false, dtoKey: "estimateitems", alias: "EstimateItem",
27
+ interfaceName: "ItemInterface", modelKebab: "item", selectorComponent: "ItemMultiSelector",
28
+ interfaceImportPath: "@/features/plm/item/data/ItemInterface",
29
+ fields: [{ name: "quantity", type: "number", tsType: "number", zodSchema: "z.number()", formComponent: "FormInputNumber", nullable: false, isContentField: false }],
30
+ })],
31
+ }));
32
+ expect(out).toContain("data.estimateItemMeta?.find");
33
+ expect(out).toContain("response.data.relationships.estimateitems");
34
+ });
35
+
36
+ it("model references and imports the named RelationshipMeta interface", () => {
37
+ const out = generateModelTemplate(makeFrontendData({
38
+ relationships: [makeRelationship({
39
+ name: "Item", single: false, dtoKey: "estimateitems", alias: "EstimateItem",
40
+ interfaceName: "ItemInterface", modelKebab: "item", selectorComponent: "ItemMultiSelector",
41
+ interfaceImportPath: "@/features/plm/item/data/ItemInterface",
42
+ fields: [{ name: "quantity", type: "number", tsType: "number", zodSchema: "z.number()", formComponent: "FormInputNumber", nullable: false, isContentField: false }],
43
+ })],
44
+ }));
45
+ expect(out).toContain("EstimateItemRelationshipMeta");
46
+ expect(out).toContain("ItemInterface & EstimateItemRelationshipMeta");
47
+ // imported from the module's own interface file (Widget)
48
+ expect(out).toMatch(/import \{[^}]*EstimateItemRelationshipMeta[^}]*\} from "@\/features\/demo\/widget\/data\/WidgetInterface"/);
49
+ });
50
+ });
51
+
52
+ describe("interface template", () => {
53
+ it("emits a named RelationshipMeta interface and a meta array in Input", () => {
54
+ const out = generateInterfaceTemplate(makeFrontendData({
55
+ relationships: [makeRelationship({
56
+ name: "Item", single: false, dtoKey: "estimateitems", alias: "EstimateItem",
57
+ interfaceName: "ItemInterface", modelKebab: "item",
58
+ interfaceImportPath: "@/features/plm/item/data/ItemInterface",
59
+ fields: [{ name: "quantity", type: "number", tsType: "number", zodSchema: "z.number()", formComponent: "FormInputNumber", nullable: false, isContentField: false }],
60
+ })],
61
+ }));
62
+ expect(out).toContain("export interface EstimateItemRelationshipMeta");
63
+ expect(out).toContain("estimateItemMeta?:");
64
+ });
65
+
66
+ it("keeps readOnly fields in the interface but out of Input", () => {
67
+ const out = generateInterfaceTemplate(makeFrontendData({
68
+ fields: [
69
+ { name: "name", type: "string", tsType: "string", zodSchema: "z.string()", formComponent: "FormInput", nullable: false, isContentField: false },
70
+ { name: "effective_value", type: "number", tsType: "number", zodSchema: "z.number()", formComponent: "FormInputNumber", nullable: true, isContentField: false, readOnly: true },
71
+ ],
72
+ }));
73
+ expect(out).toContain("get effective_value()");
74
+ const inputBlock = out.split("Input = {")[1].split("};")[0];
75
+ expect(inputBlock).not.toContain("effective_value");
76
+ });
77
+ });
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { makeFrontendData, makeRelationship } from "./fixtures";
3
+ import { resolveRelationshipKey } from "../transformers/relationship-key";
4
+
5
+ describe("frontend plumbing", () => {
6
+ it("defaults displayProp to name and activity tab on", () => {
7
+ const d = makeFrontendData();
8
+ expect(d.displayProp).toBe("name");
9
+ expect(d.containerTabs.activity).toBe(true);
10
+ });
11
+
12
+ it("resolves wire key from explicit dtoKey verbatim", () => {
13
+ expect(resolveRelationshipKey(makeRelationship({ dtoKey: "estimateitems" }))).toBe("estimateitems");
14
+ });
15
+
16
+ it("derives wire key identically to the backend when dtoKey absent", () => {
17
+ expect(resolveRelationshipKey(makeRelationship({ name: "Account", single: true }))).toBe("account");
18
+ expect(resolveRelationshipKey(makeRelationship({ name: "Person", single: false }))).toBe("persons");
19
+ expect(resolveRelationshipKey(makeRelationship({ alias: "EstimateItem", single: false }))).toBe("estimate-items");
20
+ });
21
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { makeFrontendData } from "./fixtures";
3
+ import { generateSelectorTemplate } from "../templates/components/selector.template";
4
+
5
+ describe("selector template — combobox style", () => {
6
+ it("uses Button combobox trigger + Command primitives + Loader2", () => {
7
+ const out = generateSelectorTemplate(makeFrontendData());
8
+ expect(out).toContain('role="combobox"');
9
+ expect(out).toContain("ChevronsUpDown");
10
+ expect(out).toContain("CommandInput");
11
+ expect(out).toContain("CommandEmpty");
12
+ expect(out).toContain("CommandGroup");
13
+ expect(out).toContain("Loader2");
14
+ expect(out).toContain("w-(--radix-popover-trigger-width) p-0");
15
+ });
16
+
17
+ it("drops the legacy bespoke styling", () => {
18
+ const out = generateSelectorTemplate(makeFrontendData());
19
+ expect(out).not.toContain("CircleX");
20
+ expect(out).not.toContain("RefreshCwIcon");
21
+ expect(out).not.toContain("SearchIcon");
22
+ expect(out).not.toContain("bg-input/20");
23
+ });
24
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { makeFrontendData, makeRelationship } from "./fixtures";
3
+ import { generateTableHookTemplate } from "../templates/table-hook.template";
4
+ import { generateModuleTemplate } from "../templates/module.template";
5
+ import { generateListTemplate } from "../templates/components/list.template";
6
+
7
+ describe("table-hook template", () => {
8
+ it("emits cellDate for date fields", () => {
9
+ const out = generateTableHookTemplate(makeFrontendData());
10
+ expect(out).toContain("[WidgetFields.due_date]");
11
+ expect(out).toContain("cellDate({");
12
+ });
13
+
14
+ it("emits a relationship column when showInTable", () => {
15
+ const out = generateTableHookTemplate(makeFrontendData({
16
+ relationships: [makeRelationship({ name: "Account", single: true, showInTable: true })],
17
+ }));
18
+ expect(out).toContain("widget.account?.name");
19
+ });
20
+
21
+ it("uses variant for single-relationship column key when no alias", () => {
22
+ const out = generateTableHookTemplate(makeFrontendData({
23
+ relationships: [makeRelationship({ name: "User", variant: "Author", single: true, showInTable: true })],
24
+ }));
25
+ expect(out).toContain("WidgetFields.author");
26
+ expect(out).not.toContain("WidgetFields.user]");
27
+ });
28
+
29
+ it("emits a many relationship column joined by name", () => {
30
+ const out = generateTableHookTemplate(makeFrontendData({
31
+ relationships: [makeRelationship({ name: "Tag", single: false, showInTable: true })],
32
+ }));
33
+ expect(out).toContain("WidgetFields.tags");
34
+ expect(out).toContain('.map((r) => r.name).join(", ")');
35
+ });
36
+ });
37
+
38
+ describe("module template", () => {
39
+ it("emits feature when featureId set", () => {
40
+ const out = generateModuleTemplate(makeFrontendData({ featureId: "Crm" }));
41
+ expect(out).toContain('import { FeatureIds } from "@/enums/feature.ids"');
42
+ expect(out).toContain("feature: FeatureIds.Crm");
43
+ });
44
+
45
+ it("emits cross-module inclusions", () => {
46
+ const out = generateModuleTemplate(makeFrontendData({
47
+ relatedInclusions: [{ endpoint: "accounts", fields: ["name", "image"] }],
48
+ }));
49
+ expect(out).toContain('createJsonApiInclusion("accounts", [`name`, `image`])');
50
+ });
51
+ });
52
+
53
+ describe("list template", () => {
54
+ it("forwards fullWidth to ContentListTable", () => {
55
+ const out = generateListTemplate(makeFrontendData());
56
+ expect(out).toContain("fullWidth");
57
+ expect(out).toContain("fullWidth={fullWidth}");
58
+ });
59
+ });
@@ -12,7 +12,6 @@ import {
12
12
  generateContextTemplate,
13
13
  generateDeleterTemplate,
14
14
  generateDetailPageTemplate,
15
- generateDetailsTemplate,
16
15
  generateEditorTemplate,
17
16
  generateFieldsTemplate,
18
17
  generateAliasesTemplate,
@@ -186,6 +185,10 @@ function buildTemplateData(schema: JsonModuleDefinition, targetHasNameMap?: Map<
186
185
 
187
186
  // Map fields
188
187
  const allFields = mapFields(schema.fields, names.camelCase);
188
+ allFields.forEach((f) => {
189
+ const src = schema.fields.find((sf) => sf.name === f.name);
190
+ f.readOnly = src?.readOnly === true;
191
+ });
189
192
 
190
193
  // Get inherited fields to filter out for forms (Content fields are handled specially)
191
194
  const inheritedFields = extendsContent ? ["name", "tldr", "abstract", "content"] : [];
@@ -234,6 +237,13 @@ function buildTemplateData(schema: JsonModuleDefinition, targetHasNameMap?: Map<
234
237
  imports,
235
238
  tableFieldNames,
236
239
  relationshipServiceMethods,
240
+ featureId: schema.featureId,
241
+ displayProp: schema.displayProp ?? (allFields.some((f) => f.name === "name") ? "name" : "id"),
242
+ containerTabs: {
243
+ activity: schema.containerTabs?.activity !== false,
244
+ relations: schema.containerTabs?.relations ?? [],
245
+ },
246
+ relatedInclusions: schema.inclusions?.related ?? [],
237
247
  };
238
248
  }
239
249
 
@@ -338,22 +348,13 @@ function generateAllFiles(data: FrontendTemplateData, schema: JsonModuleDefiniti
338
348
  type: "component",
339
349
  });
340
350
 
351
+ // Content component — always emitted (Details tab body)
341
352
  files.push({
342
- path: paths.details,
343
- content: generateDetailsTemplate(data),
353
+ path: paths.content,
354
+ content: generateContentTemplate(data),
344
355
  type: "component",
345
356
  });
346
357
 
347
- // Content component only for Content-extending modules
348
- const contentTemplate = generateContentTemplate(data);
349
- if (contentTemplate) {
350
- files.push({
351
- path: paths.content,
352
- content: contentTemplate,
353
- type: "component",
354
- });
355
- }
356
-
357
358
  files.push({
358
359
  path: paths.container,
359
360
  content: generateContainerTemplate(data),
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Container Template
3
3
  *
4
- * Generates {Module}Container.tsx component for displaying item detail page.
4
+ * Generates {Module}Container.tsx component for the item detail page.
5
+ *
6
+ * Emits a RoundPageContainer driven by a `tabs` array: a default "Details" tab
7
+ * rendering {Module}Content, one tab per configured relation list, and an
8
+ * optional "Activity" tab. The default export gates on delete permission.
5
9
  */
6
10
 
11
+ import { pluralize, toCamelCase, toKebabCase } from "../../transformers/name-transformer";
7
12
  import { FrontendTemplateData } from "../../types/template-data.interface";
8
13
 
9
14
  /**
@@ -13,86 +18,66 @@ import { FrontendTemplateData } from "../../types/template-data.interface";
13
18
  * @returns Generated file content
14
19
  */
15
20
  export function generateContainerTemplate(data: FrontendTemplateData): string {
16
- const { names, extendsContent } = data;
17
-
18
- // Content-extending modules get full tabs with relevant content/users
19
- if (extendsContent) {
20
- return generateContentContainerTemplate(data);
21
+ const { names, containerTabs } = data;
22
+ const camel = names.camelCase;
23
+
24
+ const relationImports: string[] = [];
25
+ const relationTabs: string[] = [];
26
+ for (const rel of containerTabs.relations) {
27
+ const relKebab = toKebabCase(rel.module);
28
+ const relPluralCamel = pluralize(toCamelCase(rel.module));
29
+ relationImports.push(
30
+ `import ${rel.module}List from "@/features/${rel.directory}/${relKebab}/components/lists/${rel.module}List";`,
31
+ );
32
+ relationTabs.push(
33
+ ` {\n key: Modules.${rel.module},\n label: t(\`entities.${relPluralCamel.toLowerCase()}\`, { count: 2 }),\n content: <${rel.module}List ${rel.listProp}={${camel}} />,\n },`,
34
+ );
21
35
  }
22
36
 
23
- // Non-content modules get simpler container
24
- return generateSimpleContainerTemplate(data);
25
- }
37
+ const tabEntries: string[] = [
38
+ ` {\n label: "Details",\n content: <${names.pascalCase}Content ${camel}={${camel}} />,\n },`,
39
+ ...relationTabs,
40
+ ];
26
41
 
27
- /**
28
- * Generate container for Content-extending modules
29
- */
30
- function generateContentContainerTemplate(data: FrontendTemplateData): string {
31
- const { names } = data;
42
+ if (containerTabs.activity) {
43
+ tabEntries.push(
44
+ ` {\n label: "Activity",\n content: <ActivityFeed module={Modules.${names.pascalCase}} entityId={${camel}.id} />,\n },`,
45
+ );
46
+ }
47
+
48
+ const activityImport = containerTabs.activity
49
+ ? `import ActivityFeed from "@/features/activity/activity-feed/components/ActivityFeed";\n`
50
+ : "";
51
+ const relationImportBlock = relationImports.length ? `${relationImports.join("\n")}\n` : "";
32
52
 
33
53
  return `"use client";
34
54
 
35
- import ${names.pascalCase}Content from "@/features/${data.importTargetDir}/${names.kebabCase}/components/details/${names.pascalCase}Content";
36
- import ${names.pascalCase}Details from "@/features/${data.importTargetDir}/${names.kebabCase}/components/details/${names.pascalCase}Details";
55
+ ${activityImport}${relationImportBlock}import { ${names.pascalCase}Content } from "@/features/${data.importTargetDir}/${names.kebabCase}/components/details/${names.pascalCase}Content";
37
56
  import { use${names.pascalCase}Context } from "@/features/${data.importTargetDir}/${names.kebabCase}/contexts/${names.pascalCase}Context";
38
57
  import { ${names.pascalCase}Interface } from "@/features/${data.importTargetDir}/${names.kebabCase}/data/${names.pascalCase}Interface";
39
- import { RoundPageContainer } from "@carlonicora/nextjs-jsonapi/components";
40
- import { Modules } from "@carlonicora/nextjs-jsonapi/core";
58
+ import { RoundPageContainer, Tab } from "@carlonicora/nextjs-jsonapi/components";
59
+ import { useCurrentUserContext } from "@carlonicora/nextjs-jsonapi/contexts";
60
+ import { Action, Modules } from "@carlonicora/nextjs-jsonapi/core";
61
+ import { useTranslations } from "next-intl";
41
62
 
42
- type ${names.pascalCase}ContainerProps = {
43
- ${names.camelCase}: ${names.pascalCase}Interface;
44
- };
63
+ type ${names.pascalCase}ContainerProps = { ${camel}: ${names.pascalCase}Interface };
45
64
 
46
- function ${names.pascalCase}ContainerInternal({ ${names.camelCase} }: ${names.pascalCase}ContainerProps) {
47
- return (
48
- <RoundPageContainer module={Modules.${names.pascalCase}} details={<${names.pascalCase}Details />}>
49
- <${names.pascalCase}Content />
50
- </RoundPageContainer>
51
- );
52
- }
65
+ function ${names.pascalCase}ContainerInternal({ ${camel} }: ${names.pascalCase}ContainerProps) {
66
+ const t = useTranslations();
53
67
 
54
- export default function ${names.pascalCase}Container() {
55
- const { ${names.camelCase} } = use${names.pascalCase}Context();
56
- if (!${names.camelCase}) return null;
68
+ const tabs: Tab[] = [
69
+ ${tabEntries.join("\n")}
70
+ ];
57
71
 
58
- return <${names.pascalCase}ContainerInternal ${names.camelCase}={${names.camelCase}} />;
59
- }
60
- `;
61
- }
62
-
63
- /**
64
- * Generate container for non-Content modules
65
- */
66
- function generateSimpleContainerTemplate(data: FrontendTemplateData): string {
67
- const { names } = data;
68
-
69
- return `"use client";
70
-
71
- import ${names.pascalCase}Details from "@/features/${data.importTargetDir}/${names.kebabCase}/components/details/${names.pascalCase}Details";
72
- import { use${names.pascalCase}Context } from "@/features/${data.importTargetDir}/${names.kebabCase}/contexts/${names.pascalCase}Context";
73
- import { ${names.pascalCase}Interface } from "@/features/${data.importTargetDir}/${names.kebabCase}/data/${names.pascalCase}Interface";
74
- import { RoundPageContainer } from "@carlonicora/nextjs-jsonapi/components";
75
- import { Modules } from "@carlonicora/nextjs-jsonapi/core";
76
-
77
- type ${names.pascalCase}ContainerProps = {
78
- ${names.camelCase}: ${names.pascalCase}Interface;
79
- };
80
-
81
- function ${names.pascalCase}ContainerInternal({ ${names.camelCase} }: ${names.pascalCase}ContainerProps) {
82
- return (
83
- <RoundPageContainer module={Modules.${names.pascalCase}} details={<${names.pascalCase}Details />}>
84
- <div className="flex w-full flex-col gap-y-4">
85
- {/* Add custom content sections here */}
86
- </div>
87
- </RoundPageContainer>
88
- );
72
+ return <RoundPageContainer module={Modules.${names.pascalCase}} id={${camel}.id} tabs={tabs} />;
89
73
  }
90
74
 
91
75
  export default function ${names.pascalCase}Container() {
92
- const { ${names.camelCase} } = use${names.pascalCase}Context();
93
- if (!${names.camelCase}) return null;
94
-
95
- return <${names.pascalCase}ContainerInternal ${names.camelCase}={${names.camelCase}} />;
76
+ const { hasPermissionToModule } = useCurrentUserContext();
77
+ const { ${camel} } = use${names.pascalCase}Context();
78
+ if (!${camel}) return null;
79
+ if (!hasPermissionToModule({ module: Modules.${names.pascalCase}, action: Action.Delete, data: ${camel} })) return null;
80
+ return <${names.pascalCase}ContainerInternal ${camel}={${camel}} />;
96
81
  }
97
82
  `;
98
83
  }