@carlonicora/nextjs-jsonapi 1.110.0 → 1.111.1

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 (146) hide show
  1. package/dist/{BlockNoteEditor-K7IIWM3S.mjs → BlockNoteEditor-A5LNFWHW.mjs} +2 -2
  2. package/dist/{BlockNoteEditor-7OKW6HHN.js → BlockNoteEditor-HNQNKCFP.js} +9 -9
  3. package/dist/{BlockNoteEditor-7OKW6HHN.js.map → BlockNoteEditor-HNQNKCFP.js.map} +1 -1
  4. package/dist/billing/index.js +299 -299
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-AKXUNE3A.js → chunk-DRJUN4R6.js} +22 -19
  7. package/dist/chunk-DRJUN4R6.js.map +1 -0
  8. package/dist/{chunk-TXVVZLUQ.mjs → chunk-LYLGU2BK.mjs} +67 -64
  9. package/dist/chunk-LYLGU2BK.mjs.map +1 -0
  10. package/dist/client/index.js +2 -2
  11. package/dist/client/index.mjs +1 -1
  12. package/dist/components/index.js +2 -2
  13. package/dist/components/index.mjs +1 -1
  14. package/dist/contexts/index.js +2 -2
  15. package/dist/contexts/index.mjs +1 -1
  16. package/dist/features/help/index.js +30 -30
  17. package/dist/features/help/index.mjs +1 -1
  18. package/dist/scripts/generate-web-module/__tests__/container.spec.d.ts +2 -0
  19. package/dist/scripts/generate-web-module/__tests__/container.spec.d.ts.map +1 -0
  20. package/dist/scripts/generate-web-module/__tests__/container.spec.js +54 -0
  21. package/dist/scripts/generate-web-module/__tests__/container.spec.js.map +1 -0
  22. package/dist/scripts/generate-web-module/__tests__/data-service.spec.d.ts +2 -0
  23. package/dist/scripts/generate-web-module/__tests__/data-service.spec.d.ts.map +1 -0
  24. package/dist/scripts/generate-web-module/__tests__/data-service.spec.js +19 -0
  25. package/dist/scripts/generate-web-module/__tests__/data-service.spec.js.map +1 -0
  26. package/dist/scripts/generate-web-module/__tests__/editor.spec.d.ts +2 -0
  27. package/dist/scripts/generate-web-module/__tests__/editor.spec.d.ts.map +1 -0
  28. package/dist/scripts/generate-web-module/__tests__/editor.spec.js +35 -0
  29. package/dist/scripts/generate-web-module/__tests__/editor.spec.js.map +1 -0
  30. package/dist/scripts/generate-web-module/__tests__/fixtures.d.ts +4 -0
  31. package/dist/scripts/generate-web-module/__tests__/fixtures.d.ts.map +1 -0
  32. package/dist/scripts/generate-web-module/__tests__/fixtures.js +45 -0
  33. package/dist/scripts/generate-web-module/__tests__/fixtures.js.map +1 -0
  34. package/dist/scripts/generate-web-module/__tests__/model-interface.spec.d.ts +2 -0
  35. package/dist/scripts/generate-web-module/__tests__/model-interface.spec.d.ts.map +1 -0
  36. package/dist/scripts/generate-web-module/__tests__/model-interface.spec.js +74 -0
  37. package/dist/scripts/generate-web-module/__tests__/model-interface.spec.js.map +1 -0
  38. package/dist/scripts/generate-web-module/__tests__/plumbing.spec.d.ts +2 -0
  39. package/dist/scripts/generate-web-module/__tests__/plumbing.spec.d.ts.map +1 -0
  40. package/dist/scripts/generate-web-module/__tests__/plumbing.spec.js +21 -0
  41. package/dist/scripts/generate-web-module/__tests__/plumbing.spec.js.map +1 -0
  42. package/dist/scripts/generate-web-module/__tests__/selector.spec.d.ts +2 -0
  43. package/dist/scripts/generate-web-module/__tests__/selector.spec.d.ts.map +1 -0
  44. package/dist/scripts/generate-web-module/__tests__/selector.spec.js +25 -0
  45. package/dist/scripts/generate-web-module/__tests__/selector.spec.js.map +1 -0
  46. package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.d.ts +2 -0
  47. package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.d.ts.map +1 -0
  48. package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.js +55 -0
  49. package/dist/scripts/generate-web-module/__tests__/table-module-list.spec.js.map +1 -0
  50. package/dist/scripts/generate-web-module/generator.d.ts.map +1 -1
  51. package/dist/scripts/generate-web-module/generator.js +14 -11
  52. package/dist/scripts/generate-web-module/generator.js.map +1 -1
  53. package/dist/scripts/generate-web-module/templates/components/container.template.d.ts +5 -1
  54. package/dist/scripts/generate-web-module/templates/components/container.template.d.ts.map +1 -1
  55. package/dist/scripts/generate-web-module/templates/components/container.template.js +43 -63
  56. package/dist/scripts/generate-web-module/templates/components/container.template.js.map +1 -1
  57. package/dist/scripts/generate-web-module/templates/components/content.template.d.ts +7 -4
  58. package/dist/scripts/generate-web-module/templates/components/content.template.d.ts.map +1 -1
  59. package/dist/scripts/generate-web-module/templates/components/content.template.js +103 -14
  60. package/dist/scripts/generate-web-module/templates/components/content.template.js.map +1 -1
  61. package/dist/scripts/generate-web-module/templates/components/editor.template.d.ts +1 -1
  62. package/dist/scripts/generate-web-module/templates/components/editor.template.js +139 -150
  63. package/dist/scripts/generate-web-module/templates/components/editor.template.js.map +1 -1
  64. package/dist/scripts/generate-web-module/templates/components/index.d.ts +0 -1
  65. package/dist/scripts/generate-web-module/templates/components/index.d.ts.map +1 -1
  66. package/dist/scripts/generate-web-module/templates/components/index.js +1 -3
  67. package/dist/scripts/generate-web-module/templates/components/index.js.map +1 -1
  68. package/dist/scripts/generate-web-module/templates/components/list-container.template.js +2 -2
  69. package/dist/scripts/generate-web-module/templates/components/list.template.d.ts.map +1 -1
  70. package/dist/scripts/generate-web-module/templates/components/list.template.js +6 -1
  71. package/dist/scripts/generate-web-module/templates/components/list.template.js.map +1 -1
  72. package/dist/scripts/generate-web-module/templates/components/selector.template.d.ts.map +1 -1
  73. package/dist/scripts/generate-web-module/templates/components/selector.template.js +48 -52
  74. package/dist/scripts/generate-web-module/templates/components/selector.template.js.map +1 -1
  75. package/dist/scripts/generate-web-module/templates/data/interface.template.d.ts.map +1 -1
  76. package/dist/scripts/generate-web-module/templates/data/interface.template.js +38 -11
  77. package/dist/scripts/generate-web-module/templates/data/interface.template.js.map +1 -1
  78. package/dist/scripts/generate-web-module/templates/data/model.template.d.ts.map +1 -1
  79. package/dist/scripts/generate-web-module/templates/data/model.template.js +83 -26
  80. package/dist/scripts/generate-web-module/templates/data/model.template.js.map +1 -1
  81. package/dist/scripts/generate-web-module/templates/data/service.template.d.ts.map +1 -1
  82. package/dist/scripts/generate-web-module/templates/data/service.template.js +21 -1
  83. package/dist/scripts/generate-web-module/templates/data/service.template.js.map +1 -1
  84. package/dist/scripts/generate-web-module/templates/index.d.ts +1 -1
  85. package/dist/scripts/generate-web-module/templates/index.d.ts.map +1 -1
  86. package/dist/scripts/generate-web-module/templates/index.js +1 -2
  87. package/dist/scripts/generate-web-module/templates/index.js.map +1 -1
  88. package/dist/scripts/generate-web-module/templates/module.template.d.ts.map +1 -1
  89. package/dist/scripts/generate-web-module/templates/module.template.js +11 -3
  90. package/dist/scripts/generate-web-module/templates/module.template.js.map +1 -1
  91. package/dist/scripts/generate-web-module/templates/table-hook.template.js +31 -1
  92. package/dist/scripts/generate-web-module/templates/table-hook.template.js.map +1 -1
  93. package/dist/scripts/generate-web-module/transformers/import-resolver.d.ts +0 -1
  94. package/dist/scripts/generate-web-module/transformers/import-resolver.d.ts.map +1 -1
  95. package/dist/scripts/generate-web-module/transformers/import-resolver.js +0 -1
  96. package/dist/scripts/generate-web-module/transformers/import-resolver.js.map +1 -1
  97. package/dist/scripts/generate-web-module/transformers/relationship-key.d.ts +5 -0
  98. package/dist/scripts/generate-web-module/transformers/relationship-key.d.ts.map +1 -0
  99. package/dist/scripts/generate-web-module/transformers/relationship-key.js +21 -0
  100. package/dist/scripts/generate-web-module/transformers/relationship-key.js.map +1 -0
  101. package/dist/scripts/generate-web-module/transformers/relationship-resolver.d.ts.map +1 -1
  102. package/dist/scripts/generate-web-module/transformers/relationship-resolver.js +2 -0
  103. package/dist/scripts/generate-web-module/transformers/relationship-resolver.js.map +1 -1
  104. package/dist/scripts/generate-web-module/types/json-schema.interface.d.ts +21 -0
  105. package/dist/scripts/generate-web-module/types/json-schema.interface.d.ts.map +1 -1
  106. package/dist/scripts/generate-web-module/types/template-data.interface.d.ts +17 -0
  107. package/dist/scripts/generate-web-module/types/template-data.interface.d.ts.map +1 -1
  108. package/package.json +1 -1
  109. package/scripts/generate-web-module/__tests__/container.spec.ts +58 -0
  110. package/scripts/generate-web-module/__tests__/data-service.spec.ts +18 -0
  111. package/scripts/generate-web-module/__tests__/editor.spec.ts +36 -0
  112. package/scripts/generate-web-module/__tests__/fixtures.ts +43 -0
  113. package/scripts/generate-web-module/__tests__/model-interface.spec.ts +77 -0
  114. package/scripts/generate-web-module/__tests__/plumbing.spec.ts +21 -0
  115. package/scripts/generate-web-module/__tests__/selector.spec.ts +24 -0
  116. package/scripts/generate-web-module/__tests__/table-module-list.spec.ts +59 -0
  117. package/scripts/generate-web-module/generator.ts +14 -13
  118. package/scripts/generate-web-module/templates/components/container.template.ts +51 -66
  119. package/scripts/generate-web-module/templates/components/content.template.ts +107 -15
  120. package/scripts/generate-web-module/templates/components/editor.template.ts +140 -152
  121. package/scripts/generate-web-module/templates/components/index.ts +0 -1
  122. package/scripts/generate-web-module/templates/components/list-container.template.ts +2 -2
  123. package/scripts/generate-web-module/templates/components/list.template.ts +6 -1
  124. package/scripts/generate-web-module/templates/components/selector.template.ts +48 -52
  125. package/scripts/generate-web-module/templates/data/interface.template.ts +44 -12
  126. package/scripts/generate-web-module/templates/data/model.template.ts +88 -28
  127. package/scripts/generate-web-module/templates/data/service.template.ts +23 -1
  128. package/scripts/generate-web-module/templates/index.ts +0 -1
  129. package/scripts/generate-web-module/templates/module.template.ts +13 -3
  130. package/scripts/generate-web-module/templates/table-hook.template.ts +31 -2
  131. package/scripts/generate-web-module/transformers/import-resolver.ts +0 -2
  132. package/scripts/generate-web-module/transformers/relationship-key.ts +18 -0
  133. package/scripts/generate-web-module/transformers/relationship-resolver.ts +2 -0
  134. package/scripts/generate-web-module/types/json-schema.interface.ts +9 -0
  135. package/scripts/generate-web-module/types/template-data.interface.ts +7 -0
  136. package/src/features/notification/components/modals/NotificationModal.tsx +2 -5
  137. package/src/features/notification/contexts/NotificationContext.tsx +29 -13
  138. package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +70 -0
  139. package/dist/chunk-AKXUNE3A.js.map +0 -1
  140. package/dist/chunk-TXVVZLUQ.mjs.map +0 -1
  141. package/dist/scripts/generate-web-module/templates/components/details.template.d.ts +0 -14
  142. package/dist/scripts/generate-web-module/templates/components/details.template.d.ts.map +0 -1
  143. package/dist/scripts/generate-web-module/templates/components/details.template.js +0 -112
  144. package/dist/scripts/generate-web-module/templates/components/details.template.js.map +0 -1
  145. package/scripts/generate-web-module/templates/components/details.template.ts +0 -116
  146. /package/dist/{BlockNoteEditor-K7IIWM3S.mjs.map → BlockNoteEditor-A5LNFWHW.mjs.map} +0 -0
@@ -12,7 +12,6 @@ export { generateMultiSelectorTemplate } from "./multi-selector.template";
12
12
 
13
13
  // Display components
14
14
  export { generateListTemplate } from "./list.template";
15
- export { generateDetailsTemplate } from "./details.template";
16
15
  export { generateContentTemplate } from "./content.template";
17
16
  export { generateContainerTemplate } from "./container.template";
18
17
  export { generateListContainerTemplate } from "./list-container.template";
@@ -23,8 +23,8 @@ import { Modules } from "@carlonicora/nextjs-jsonapi/core";
23
23
 
24
24
  function ${names.pascalCase}ListContainerInternal() {
25
25
  return (
26
- <RoundPageContainer module={Modules.${names.pascalCase}}>
27
- <${names.pascalCase}List />
26
+ <RoundPageContainer module={Modules.${names.pascalCase}} fullWidth>
27
+ <${names.pascalCase}List fullWidth />
28
28
  </RoundPageContainer>
29
29
  );
30
30
  }
@@ -32,7 +32,11 @@ import { DataListRetriever, useDataListRetriever } from "@carlonicora/nextjs-jso
32
32
  import { useTranslations } from "next-intl";
33
33
  import { ReactNode } from "react";
34
34
 
35
- export default function ${names.pascalCase}List() {
35
+ type ${names.pascalCase}ListProps = {
36
+ fullWidth?: boolean;
37
+ };
38
+
39
+ export default function ${names.pascalCase}List({ fullWidth }: ${names.pascalCase}ListProps) {
36
40
  const t = useTranslations();
37
41
 
38
42
  const data: DataListRetriever<${names.pascalCase}Interface> = useDataListRetriever({
@@ -50,6 +54,7 @@ export default function ${names.pascalCase}List() {
50
54
  tableGeneratorType={Modules.${names.pascalCase}}
51
55
  functions={functions}
52
56
  title={t(\`entities.${names.pluralCamel.toLowerCase()}\`, { count: 2 })}
57
+ fullWidth={fullWidth}
53
58
  />
54
59
  );
55
60
  }
@@ -21,11 +21,14 @@ export function generateSelectorTemplate(data: FrontendTemplateData): string {
21
21
  return `"use client";
22
22
 
23
23
  import {
24
+ Button,
24
25
  Command,
26
+ CommandEmpty,
27
+ CommandGroup,
28
+ CommandInput,
25
29
  CommandItem,
26
30
  CommandList,
27
31
  FormFieldWrapper,
28
- Input,
29
32
  Popover,
30
33
  PopoverContent,
31
34
  PopoverTrigger,
@@ -36,7 +39,7 @@ import { DataListRetriever, useDataListRetriever } from "@carlonicora/nextjs-jso
36
39
  import { useDebounce } from "@carlonicora/nextjs-jsonapi/client";
37
40
  import { Modules } from "@carlonicora/nextjs-jsonapi/core";
38
41
 
39
- import { CircleX, RefreshCwIcon, SearchIcon, XIcon } from "lucide-react";
42
+ import { ChevronsUpDown, Loader2, XIcon } from "lucide-react";
40
43
  import { useTranslations } from "next-intl";
41
44
  import { useCallback, useEffect, useRef, useState } from "react";
42
45
 
@@ -98,12 +101,12 @@ export default function ${names.pascalCase}Selector({
98
101
  const set${names.pascalCase} = (${names.camelCase}?: ${names.pascalCase}Interface) => {
99
102
  if (onChange) onChange(${names.camelCase});
100
103
  if (!${names.camelCase}) {
101
- form.setValue(id, undefined, { shouldDirty: true });
104
+ form.setValue(id, undefined);
102
105
  setOpen(false);
103
106
  return;
104
107
  }
105
108
 
106
- form.setValue(id, { id: ${names.camelCase}.id, name: ${names.camelCase}.${displayProp} }, { shouldDirty: true });
109
+ form.setValue(id, { id: ${names.camelCase}.id, name: ${names.camelCase}.${displayProp} });
107
110
  setOpen(false);
108
111
 
109
112
  setTimeout(() => {
@@ -118,62 +121,55 @@ export default function ${names.pascalCase}Selector({
118
121
  <Popover open={open} onOpenChange={setOpen} modal={true}>
119
122
  <div className="flex w-full flex-row items-center justify-between">
120
123
  <PopoverTrigger className="w-full">
121
- <div className="flex w-full flex-row items-center justify-start rounded-md">
122
- {field.value ? (
123
- <div className="bg-input/20 dark:bg-input/30 border-input flex h-7 w-full flex-row items-center justify-start rounded-md border px-2 py-0.5 text-sm md:text-xs/relaxed">
124
- <span>{field.value?.name ?? ""}</span>
125
- </div>
126
- ) : (
127
- <div className="bg-input/20 dark:bg-input/30 border-input text-muted-foreground flex h-7 w-full flex-row items-center justify-start rounded-md border px-2 py-0.5 text-sm md:text-xs/relaxed">
128
- {placeholder ?? t(\`generic.search.placeholder\`, { type: t(\`entities.${i18nKey}\`, { count: 1 }) })}
129
- </div>
130
- )}
131
- </div>
124
+ <Button
125
+ variant="outline"
126
+ role="combobox"
127
+ aria-expanded={open}
128
+ className="h-auto min-h-10 w-full justify-between px-3 py-2"
129
+ >
130
+ <span className={field.value ? "" : "text-muted-foreground"}>
131
+ {field.value?.name ?? placeholder ?? t(\`generic.search.placeholder\`, { type: t(\`entities.${i18nKey}\`, { count: 1 }) })}
132
+ </span>
133
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
134
+ </Button>
132
135
  </PopoverTrigger>
133
136
  {field.value && (
134
- <CircleX
135
- className="text-muted hover:text-destructive ml-2 h-4 w-4 shrink-0 cursor-pointer"
137
+ <XIcon
138
+ className="text-muted-foreground hover:text-destructive ml-2 h-5 w-5 cursor-pointer"
136
139
  onClick={() => set${names.pascalCase}()}
137
140
  />
138
141
  )}
139
142
  </div>
140
- <PopoverContent align="start" className="w-(--anchor-width)">
143
+ <PopoverContent className="w-(--radix-popover-trigger-width) p-0" align="start">
141
144
  <Command shouldFilter={false}>
142
- <div className="relative mb-2 w-full">
143
- <SearchIcon className="text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4" />
144
- <Input
145
- placeholder={t(\`generic.search.placeholder\`, { type: t(\`entities.${i18nKey}\`, { count: 1 }) })}
146
- type="text"
147
- className="w-full pr-8 pl-8"
148
- onChange={(e) => setSearchTerm(e.target.value)}
149
- value={searchTerm}
150
- />
151
- {isSearching ? (
152
- <RefreshCwIcon className="text-muted-foreground absolute top-2.5 right-2.5 h-4 w-4 animate-spin" />
153
- ) : searchTermRef.current ? (
154
- <XIcon
155
- className={\`absolute top-2.5 right-2.5 h-4 w-4 \${searchTermRef.current ? "cursor-pointer" : "text-muted-foreground"}\`}
156
- onClick={() => {
157
- setSearchTerm("");
158
- search("");
159
- }}
160
- />
161
- ) : (
162
- <></>
163
- )}
164
- </div>
145
+ <CommandInput
146
+ placeholder={t(\`generic.search.placeholder\`, { type: t(\`entities.${i18nKey}\`, { count: 1 }) })}
147
+ value={searchTerm}
148
+ onValueChange={setSearchTerm}
149
+ />
165
150
  <CommandList>
166
- {data.data &&
167
- data.data.length > 0 &&
168
- (data.data as ${names.pascalCase}Interface[]).map((${names.camelCase}: ${names.pascalCase}Interface) => (
169
- <CommandItem
170
- className="cursor-pointer hover:bg-muted data-selected:hover:bg-muted bg-transparent data-selected:bg-transparent"
171
- key={${names.camelCase}.id}
172
- onSelect={() => set${names.pascalCase}(${names.camelCase})}
173
- >
174
- {${names.camelCase}.${displayProp}}
175
- </CommandItem>
176
- ))}
151
+ {isSearching && (
152
+ <div className="flex items-center justify-center py-6">
153
+ <Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
154
+ </div>
155
+ )}
156
+ {!isSearching && data.data && data.data.length === 0 && (
157
+ <CommandEmpty>{t(\`generic.search.no_results\`)}</CommandEmpty>
158
+ )}
159
+ {!isSearching && data.data && data.data.length > 0 && (
160
+ <CommandGroup>
161
+ {(data.data as ${names.pascalCase}Interface[]).map((${names.camelCase}: ${names.pascalCase}Interface) => (
162
+ <CommandItem
163
+ key={${names.camelCase}.id}
164
+ value={${names.camelCase}.id}
165
+ onSelect={() => set${names.pascalCase}(${names.camelCase})}
166
+ className="hover:bg-muted data-selected:hover:bg-muted bg-transparent data-selected:bg-transparent cursor-pointer"
167
+ >
168
+ {${names.camelCase}.${displayProp}}
169
+ </CommandItem>
170
+ ))}
171
+ </CommandGroup>
172
+ )}
177
173
  </CommandList>
178
174
  </Command>
179
175
  </PopoverContent>
@@ -5,7 +5,15 @@
5
5
  */
6
6
 
7
7
  import { FrontendTemplateData, FrontendField, FrontendRelationship } from "../../types/template-data.interface";
8
- import { toCamelCase, pluralize } from "../../transformers/name-transformer";
8
+ import { toCamelCase, toPascalCase, pluralize } from "../../transformers/name-transformer";
9
+
10
+ /**
11
+ * Build the named `<Pascal>RelationshipMeta` interface name for a relationship
12
+ * that carries edge fields (mirrors live OpportunityInterface.ts).
13
+ */
14
+ function relationshipMetaName(rel: FrontendRelationship): string {
15
+ return `${toPascalCase(rel.alias ?? rel.name)}RelationshipMeta`;
16
+ }
9
17
 
10
18
  /**
11
19
  * Generate the interface file content
@@ -17,17 +25,35 @@ export function generateInterfaceTemplate(data: FrontendTemplateData): string {
17
25
  const { names, extendsContent, fields, relationships } = data;
18
26
 
19
27
  const imports = generateImports(data);
28
+ const metaInterfaces = generateMetaInterfaces(data);
20
29
  const inputType = generateInputType(data);
21
30
  const interfaceDefinition = generateInterface(data);
22
31
 
23
32
  return `${imports}
24
-
33
+ ${metaInterfaces ? `\n${metaInterfaces}\n` : ""}
25
34
  ${inputType}
26
35
 
27
36
  ${interfaceDefinition}
28
37
  `;
29
38
  }
30
39
 
40
+ /**
41
+ * Generate named `<Pascal>RelationshipMeta` interfaces for relationships that
42
+ * carry edge fields. Reused by getters and the Input type's meta arrays.
43
+ */
44
+ function generateMetaInterfaces(data: FrontendTemplateData): string {
45
+ return data.relationships
46
+ .filter((rel) => rel.fields && rel.fields.length > 0)
47
+ .map((rel) => {
48
+ const metaName = relationshipMetaName(rel);
49
+ const body = rel
50
+ .fields!.map((f) => ` ${f.name}${f.nullable ? "?" : ""}: ${f.tsType};`)
51
+ .join("\n");
52
+ return `export interface ${metaName} {\n${body}\n}`;
53
+ })
54
+ .join("\n\n");
55
+ }
56
+
31
57
  /**
32
58
  * Generate import statements
33
59
  */
@@ -64,10 +90,11 @@ function generateInputType(data: FrontendTemplateData): string {
64
90
 
65
91
  const fieldLines: string[] = [" id: string;"];
66
92
 
67
- // Add fields (excluding inherited ones if extending Content)
68
- const fieldsToInclude = extendsContent
69
- ? fields.filter((f) => !["name", "tldr", "abstract"].includes(f.name))
70
- : fields;
93
+ // Add fields (excluding inherited ones if extending Content, and read-only
94
+ // fields which are server-derived and never sent back in the Input).
95
+ const fieldsToInclude = (
96
+ extendsContent ? fields.filter((f) => !["name", "tldr", "abstract"].includes(f.name)) : fields
97
+ ).filter((f) => !f.readOnly);
71
98
 
72
99
  fieldsToInclude.forEach((field) => {
73
100
  const optional = field.nullable ? "?" : "";
@@ -93,6 +120,12 @@ function generateInputType(data: FrontendTemplateData): string {
93
120
  const effectiveMany = rel.alias || rel.name;
94
121
  const key = `${toCamelCase(effectiveMany)}Ids`;
95
122
  fieldLines.push(` ${key}?: string[];`);
123
+
124
+ // For many relationships with edge fields, also emit a parallel meta array.
125
+ if (rel.fields && rel.fields.length > 0) {
126
+ const metaShape = rel.fields.map((f) => `${f.name}: ${f.tsType}`).join("; ");
127
+ fieldLines.push(` ${toCamelCase(effectiveMany)}Meta?: { id: string; ${metaShape} }[];`);
128
+ }
96
129
  }
97
130
  });
98
131
 
@@ -130,11 +163,11 @@ function generateInterface(data: FrontendTemplateData): string {
130
163
  if (rel.single) {
131
164
  const propertyName = toCamelCase(effectiveName);
132
165
 
133
- // Build return type - use intersection if relationship has fields
166
+ // Build return type - intersect with the named meta interface if the
167
+ // relationship has edge fields.
134
168
  let baseType = rel.interfaceName;
135
169
  if (rel.fields && rel.fields.length > 0) {
136
- const metaFields = rel.fields.map((f) => `${f.name}?: ${f.tsType}`).join("; ");
137
- baseType = `${rel.interfaceName} & { ${metaFields} }`;
170
+ baseType = `${rel.interfaceName} & ${relationshipMetaName(rel)}`;
138
171
  }
139
172
 
140
173
  const type = rel.nullable ? `(${baseType}) | undefined` : baseType;
@@ -142,10 +175,9 @@ function generateInterface(data: FrontendTemplateData): string {
142
175
  } else {
143
176
  const effectiveMany = rel.alias || rel.name;
144
177
  const propertyName = pluralize(toCamelCase(effectiveMany));
145
- // Use intersection type if relationship has fields (edge properties)
178
+ // Intersect with the named meta interface if the relationship has edge fields.
146
179
  if (rel.fields && rel.fields.length > 0) {
147
- const metaFields = rel.fields.map((f) => `${f.name}?: ${f.tsType}`).join("; ");
148
- getterLines.push(` get ${propertyName}(): (${rel.interfaceName} & { ${metaFields} })[];`);
180
+ getterLines.push(` get ${propertyName}(): (${rel.interfaceName} & ${relationshipMetaName(rel)})[];`);
149
181
  } else {
150
182
  getterLines.push(` get ${propertyName}(): ${rel.interfaceName}[];`);
151
183
  }
@@ -4,9 +4,33 @@
4
4
  * Generates {Module}.ts class file with rehydrate and createJsonApi methods.
5
5
  */
6
6
 
7
- import { pluralize, toCamelCase } from "../../transformers/name-transformer";
7
+ import { pluralize, toCamelCase, toPascalCase } from "../../transformers/name-transformer";
8
+ import { resolveRelationshipKey } from "../../transformers/relationship-key";
8
9
  import { AUTHOR_VARIANT } from "../../types/field-mapping.types";
9
- import { FrontendField, FrontendTemplateData } from "../../types/template-data.interface";
10
+ import { FrontendField, FrontendRelationship, FrontendTemplateData } from "../../types/template-data.interface";
11
+
12
+ /**
13
+ * Build the named `<Pascal>RelationshipMeta` interface name for a relationship
14
+ * that carries edge fields (mirrors live Opportunity.ts / OpportunityInterface.ts).
15
+ *
16
+ * These named interfaces are declared in the module's OWN `<Module>Interface.ts`
17
+ * (emitted by interface.template.ts), so the model imports them from there.
18
+ */
19
+ function relMetaName(rel: FrontendRelationship): string {
20
+ return `${toPascalCase(rel.alias ?? rel.name)}RelationshipMeta`;
21
+ }
22
+
23
+ /**
24
+ * Determine whether the model needs the `formatLocalDate` helper imported.
25
+ *
26
+ * True when any data field is a date/datetime, or any relationship edge field
27
+ * is a date/datetime (edge dates would also be serialized via the helper).
28
+ */
29
+ function hasDateField(data: FrontendTemplateData): boolean {
30
+ const fieldIsDate = (type: string) => type === "date" || type === "datetime";
31
+ if (data.fields.some((f) => fieldIsDate(f.type))) return true;
32
+ return data.relationships.some((rel) => (rel.fields ?? []).some((f) => fieldIsDate(f.type)));
33
+ }
10
34
 
11
35
  /**
12
36
  * Generate the model file content
@@ -46,9 +70,20 @@ function generateImports(data: FrontendTemplateData): string {
46
70
  const { names, extendsContent, relationships } = data;
47
71
  const imports: string[] = [];
48
72
 
49
- // Own interface import
73
+ // Own interface import — also pull in the named `<Pascal>RelationshipMeta`
74
+ // interfaces, which are all declared in this module's own Interface file.
75
+ const ownNamedImports = [`${names.pascalCase}Input`, `${names.pascalCase}Interface`];
76
+ const seenMeta = new Set<string>();
77
+ relationships.forEach((rel) => {
78
+ if (!rel.fields || rel.fields.length === 0) return;
79
+ const metaName = relMetaName(rel);
80
+ if (seenMeta.has(metaName)) return;
81
+ seenMeta.add(metaName);
82
+ ownNamedImports.push(metaName);
83
+ });
84
+ ownNamedImports.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
50
85
  imports.push(
51
- `import { ${names.pascalCase}Input, ${names.pascalCase}Interface } from "@/features/${data.importTargetDir}/${names.kebabCase}/data/${names.pascalCase}Interface";`,
86
+ `import { ${ownNamedImports.join(", ")} } from "@/features/${data.importTargetDir}/${names.kebabCase}/data/${names.pascalCase}Interface";`,
52
87
  );
53
88
 
54
89
  // Relationship interface imports (deduplicated, skip self-referential)
@@ -62,13 +97,16 @@ function generateImports(data: FrontendTemplateData): string {
62
97
  imports.push(`import { ${rel.interfaceName} } from "${rel.interfaceImportPath}";`);
63
98
  });
64
99
 
65
- // Base class and core imports
100
+ // Base class and core imports (sorted case-insensitively to match live output)
101
+ const byName = (a: string, b: string) => a.toLowerCase().localeCompare(b.toLowerCase());
102
+ const coreNames = ["JsonApiHydratedDataInterface", "Modules"];
103
+ if (hasDateField(data)) coreNames.push("formatLocalDate");
66
104
  if (extendsContent) {
67
105
  imports.push(`import { Content } from "@/features/content/data/Content";`);
68
- imports.push(`import { JsonApiHydratedDataInterface, Modules } from "@carlonicora/nextjs-jsonapi/core";`);
106
+ imports.push(`import { ${coreNames.sort(byName).join(", ")} } from "@carlonicora/nextjs-jsonapi/core";`);
69
107
  } else {
70
108
  imports.push(
71
- `import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "@carlonicora/nextjs-jsonapi/core";`,
109
+ `import { ${["AbstractApiData", ...coreNames].sort(byName).join(", ")} } from "@carlonicora/nextjs-jsonapi/core";`,
72
110
  );
73
111
  }
74
112
 
@@ -97,20 +135,18 @@ function generatePrivateFields(data: FrontendTemplateData): string {
97
135
  const effectiveName = rel.alias || rel.variant || rel.name;
98
136
  if (rel.single) {
99
137
  const propName = toCamelCase(effectiveName);
100
- // Use intersection type if relationship has fields
138
+ // Use intersection with the named meta interface if relationship has fields
101
139
  let typeDecl = rel.interfaceName;
102
140
  if (rel.fields && rel.fields.length > 0) {
103
- const metaFields = rel.fields.map((f) => `${f.name}?: ${f.tsType}`).join("; ");
104
- typeDecl = `${rel.interfaceName} & { ${metaFields} }`;
141
+ typeDecl = `${rel.interfaceName} & ${relMetaName(rel)}`;
105
142
  }
106
143
  lines.push(` private _${propName}?: ${typeDecl};`);
107
144
  } else {
108
145
  const effectiveMany = rel.alias || rel.name;
109
146
  const propName = pluralize(toCamelCase(effectiveMany));
110
- // Use intersection type if relationship has fields (edge properties)
147
+ // Use intersection with the named meta interface if relationship has fields (edge properties)
111
148
  if (rel.fields && rel.fields.length > 0) {
112
- const metaFields = rel.fields.map((f) => `${f.name}?: ${f.tsType}`).join("; ");
113
- lines.push(` private _${propName}?: (${rel.interfaceName} & { ${metaFields} })[];`);
149
+ lines.push(` private _${propName}?: (${rel.interfaceName} & ${relMetaName(rel)})[];`);
114
150
  } else {
115
151
  lines.push(` private _${propName}?: ${rel.interfaceName}[];`);
116
152
  }
@@ -147,11 +183,10 @@ function generateGetters(data: FrontendTemplateData): string {
147
183
  if (rel.single) {
148
184
  const propName = toCamelCase(effectiveName);
149
185
 
150
- // Build return type - use intersection if relationship has fields
186
+ // Build return type - intersect with the named meta interface if relationship has fields
151
187
  let baseType = rel.interfaceName;
152
188
  if (rel.fields && rel.fields.length > 0) {
153
- const metaFields = rel.fields.map((f) => `${f.name}?: ${f.tsType}`).join("; ");
154
- baseType = `${rel.interfaceName} & { ${metaFields} }`;
189
+ baseType = `${rel.interfaceName} & ${relMetaName(rel)}`;
155
190
  }
156
191
 
157
192
  if (rel.nullable) {
@@ -167,10 +202,9 @@ function generateGetters(data: FrontendTemplateData): string {
167
202
  } else {
168
203
  const effectiveMany = rel.alias || rel.name;
169
204
  const propName = pluralize(toCamelCase(effectiveMany));
170
- // Use intersection type if relationship has fields (edge properties)
205
+ // Intersect with the named meta interface if relationship has fields (edge properties)
171
206
  if (rel.fields && rel.fields.length > 0) {
172
- const metaFields = rel.fields.map((f) => `${f.name}?: ${f.tsType}`).join("; ");
173
- lines.push(` get ${propName}(): (${rel.interfaceName} & { ${metaFields} })[] {
207
+ lines.push(` get ${propName}(): (${rel.interfaceName} & ${relMetaName(rel)})[] {
174
208
  return this._${propName} ?? [];
175
209
  }`);
176
210
  } else {
@@ -223,11 +257,11 @@ function generateRehydrateMethod(data: FrontendTemplateData): string {
223
257
  const effectiveName = rel.alias || rel.variant || rel.name;
224
258
  if (rel.single) {
225
259
  const propName = toCamelCase(effectiveName);
226
- const relationshipKey = toCamelCase(effectiveName);
260
+ const relationshipKey = resolveRelationshipKey(rel);
227
261
 
228
262
  // Use _readIncludedWithMeta for relationships with fields
229
263
  if (rel.fields && rel.fields.length > 0) {
230
- const metaType = `{ ${rel.fields.map((f) => `${f.name}?: ${f.tsType}`).join("; ")} }`;
264
+ const metaType = relMetaName(rel);
231
265
  const singleCast = rel.nullable
232
266
  ? ` as (${rel.interfaceName} & ${metaType}) | undefined`
233
267
  : ` as ${rel.interfaceName} & ${metaType}`;
@@ -242,11 +276,11 @@ function generateRehydrateMethod(data: FrontendTemplateData): string {
242
276
  } else {
243
277
  const effectiveMany = rel.alias || rel.name;
244
278
  const propName = pluralize(toCamelCase(effectiveMany));
245
- const relationshipKey = toCamelCase(effectiveMany);
279
+ const relationshipKey = resolveRelationshipKey(rel);
246
280
 
247
281
  // Use _readIncludedWithMeta for relationships with fields (edge properties)
248
282
  if (rel.fields && rel.fields.length > 0) {
249
- const metaType = `{ ${rel.fields.map((f) => `${f.name}?: ${f.tsType}`).join("; ")} }`;
283
+ const metaType = relMetaName(rel);
250
284
  lines.push(
251
285
  ` this._${propName} = this._readIncludedWithMeta<${rel.interfaceName}, ${metaType}>(data, "${relationshipKey}", Modules.${rel.name}) as (${rel.interfaceName} & ${metaType})[];`,
252
286
  );
@@ -292,16 +326,25 @@ function generateCreateJsonApiMethod(data: FrontendTemplateData): string {
292
326
  lines.push(``);
293
327
  }
294
328
 
295
- // Field serialization (excluding inherited)
296
- const fieldsToInclude = extendsContent
297
- ? fields.filter((f) => !["name", "tldr", "abstract"].includes(f.name))
298
- : fields;
329
+ // Field serialization (excluding inherited and read-only fields).
330
+ // read-only fields are server-derived: present in rehydrate/getters but never sent back.
331
+ const fieldsToInclude = (
332
+ extendsContent ? fields.filter((f) => !["name", "tldr", "abstract"].includes(f.name)) : fields
333
+ ).filter((f) => !f.readOnly);
299
334
 
300
335
  fieldsToInclude.forEach((field) => {
301
336
  if (field.isContentField) {
302
337
  lines.push(
303
338
  ` if (data.${field.name} !== undefined) response.data.attributes.${field.name} = JSON.stringify(data.${field.name});`,
304
339
  );
340
+ } else if (field.type === "date") {
341
+ lines.push(
342
+ ` if (data.${field.name} !== undefined) response.data.attributes.${field.name} = formatLocalDate(data.${field.name});`,
343
+ );
344
+ } else if (field.type === "datetime") {
345
+ lines.push(
346
+ ` if (data.${field.name} !== undefined) response.data.attributes.${field.name} = data.${field.name}.toISOString();`,
347
+ );
305
348
  } else {
306
349
  lines.push(
307
350
  ` if (data.${field.name} !== undefined) response.data.attributes.${field.name} = data.${field.name};`,
@@ -317,7 +360,7 @@ function generateCreateJsonApiMethod(data: FrontendTemplateData): string {
317
360
  relationshipsToSerialize.forEach((rel) => {
318
361
  const effectiveName = rel.alias || rel.variant || rel.name;
319
362
  const payloadKey = rel.single ? `${toCamelCase(effectiveName)}Id` : `${toCamelCase(effectiveName)}Ids`;
320
- const relationshipKey = toCamelCase(effectiveName);
363
+ const relationshipKey = resolveRelationshipKey(rel);
321
364
 
322
365
  if (rel.single) {
323
366
  lines.push(` if (data.${payloadKey}) {`);
@@ -337,6 +380,23 @@ function generateCreateJsonApiMethod(data: FrontendTemplateData): string {
337
380
  lines.push(` },`);
338
381
  }
339
382
 
383
+ lines.push(` };`);
384
+ lines.push(` }`);
385
+ } else if (rel.fields && rel.fields.length > 0) {
386
+ // MANY relationship with edge fields: per-item meta resolved from a meta array.
387
+ const metaProp = `${toCamelCase(effectiveName)}Meta`;
388
+ lines.push(` if (data.${payloadKey} !== undefined) {`);
389
+ lines.push(` response.data.relationships.${relationshipKey} = {`);
390
+ lines.push(` data: data.${payloadKey}.map((id) => {`);
391
+ lines.push(` const meta = data.${metaProp}?.find((m) => m.id === id);`);
392
+ lines.push(` return {`);
393
+ lines.push(` type: Modules.${rel.name}.name,`);
394
+ lines.push(` id,`);
395
+ lines.push(
396
+ ` ...(meta ? { meta: { ${rel.fields.map((f) => `${f.name}: meta.${f.name}`).join(", ")} } } : {}),`,
397
+ );
398
+ lines.push(` };`);
399
+ lines.push(` }),`);
340
400
  lines.push(` };`);
341
401
  lines.push(` }`);
342
402
  } else {
@@ -22,6 +22,7 @@ export function generateServiceTemplate(data: FrontendTemplateData): string {
22
22
  const relationshipMethods = generateRelationshipMethods(data);
23
23
  const createMethod = generateCreateMethod(data);
24
24
  const updateMethod = generateUpdateMethod(data);
25
+ const patchMethod = generatePatchMethod(data);
25
26
  const deleteMethod = generateDeleteMethod(data);
26
27
 
27
28
  return `${imports}
@@ -36,6 +37,8 @@ ${createMethod}
36
37
 
37
38
  ${updateMethod}
38
39
 
40
+ ${patchMethod}
41
+
39
42
  ${deleteMethod}
40
43
  }
41
44
  `;
@@ -60,10 +63,13 @@ function generateFindOneMethod(data: FrontendTemplateData): string {
60
63
  const { names } = data;
61
64
 
62
65
  return ` static async findOne(params: { id: string }): Promise<${names.pascalCase}Interface> {
66
+ const endpoint = new EndpointCreator({ endpoint: Modules.${names.pascalCase}, id: params.id });
67
+ if (Modules.${names.pascalCase}.inclusions?.lists?.fields) endpoint.limitToFields(Modules.${names.pascalCase}.inclusions.lists.fields);
68
+ if (Modules.${names.pascalCase}.inclusions?.lists?.types) endpoint.limitToType(Modules.${names.pascalCase}.inclusions.lists.types);
63
69
  return this.callApi<${names.pascalCase}Interface>({
64
70
  type: Modules.${names.pascalCase},
65
71
  method: HttpMethod.GET,
66
- endpoint: new EndpointCreator({ endpoint: Modules.${names.pascalCase}, id: params.id }).generate(),
72
+ endpoint: endpoint.generate(),
67
73
  });
68
74
  }`;
69
75
  }
@@ -174,6 +180,22 @@ function generateUpdateMethod(data: FrontendTemplateData): string {
174
180
  }`;
175
181
  }
176
182
 
183
+ /**
184
+ * Generate patch method
185
+ */
186
+ function generatePatchMethod(data: FrontendTemplateData): string {
187
+ const { names } = data;
188
+
189
+ return ` static async patch(params: { id: string } & Partial<Omit<${names.pascalCase}Input, "id">>): Promise<${names.pascalCase}Interface> {
190
+ return this.callApi({
191
+ type: Modules.${names.pascalCase},
192
+ method: HttpMethod.PATCH,
193
+ endpoint: new EndpointCreator({ endpoint: Modules.${names.pascalCase}, id: params.id }).generate(),
194
+ input: params,
195
+ });
196
+ }`;
197
+ }
198
+
177
199
  /**
178
200
  * Generate delete method
179
201
  */
@@ -20,7 +20,6 @@ export {
20
20
  generateSelectorTemplate,
21
21
  generateMultiSelectorTemplate,
22
22
  generateListTemplate,
23
- generateDetailsTemplate,
24
23
  generateContentTemplate,
25
24
  generateContainerTemplate,
26
25
  generateListContainerTemplate,
@@ -20,11 +20,21 @@ export function generateModuleTemplate(data: FrontendTemplateData): string {
20
20
  const listFieldNames = getListFieldNames(data);
21
21
  const listInclusionFields = listFieldNames.map((f) => `\`${f}\``).join(", ");
22
22
 
23
+ // Feature gating (optional)
24
+ const featureImport = data.featureId ? `import { FeatureIds } from "@/enums/feature.ids";\n` : "";
25
+ const featureLine = data.featureId ? ` feature: FeatureIds.${data.featureId},\n` : "";
26
+
27
+ // Cross-module inclusions for related entities (optional)
28
+ const relatedInclusionLines = data.relatedInclusions
29
+ .map((inc) => ` createJsonApiInclusion("${inc.endpoint}", [${inc.fields.map((f) => `\`${f}\``).join(", ")}]),`)
30
+ .join("\n");
31
+ const relatedInclusionBlock = relatedInclusionLines ? `\n${relatedInclusionLines}` : "";
32
+
23
33
  return `import { ${names.pascalCase} } from "@/features/${data.importTargetDir}/${names.kebabCase}/data/${names.pascalCase}";
24
34
  import { createJsonApiInclusion } from "@carlonicora/nextjs-jsonapi/core";
25
35
  import { ModuleFactory } from "@carlonicora/nextjs-jsonapi/core";
26
36
  import { ${DEFAULT_MODULE_ICON} } from "lucide-react";
27
-
37
+ ${featureImport}
28
38
  export const ${names.pascalCase}Module = (factory: ModuleFactory) =>
29
39
  factory({
30
40
  pageUrl: "/${names.pluralKebab}",
@@ -32,11 +42,11 @@ export const ${names.pascalCase}Module = (factory: ModuleFactory) =>
32
42
  model: ${names.pascalCase},
33
43
  moduleId: "${moduleId}",
34
44
  icon: ${DEFAULT_MODULE_ICON},
35
- inclusions: {
45
+ ${featureLine} inclusions: {
36
46
  lists: {
37
47
  fields: [
38
48
  createJsonApiInclusion("${endpoint}", [${listInclusionFields}]),
39
- createJsonApiInclusion("users", [\`name\`, \`avatar\`]),
49
+ createJsonApiInclusion("users", [\`name\`, \`avatar\`]),${relatedInclusionBlock}
40
50
  ],
41
51
  },
42
52
  },