@postxl/generators 1.18.0 → 1.19.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 (90) hide show
  1. package/dist/backend-actions/actions.generator.d.ts +1 -0
  2. package/dist/backend-actions/actions.generator.js +6 -0
  3. package/dist/backend-actions/actions.generator.js.map +1 -1
  4. package/dist/backend-actions/generators/actions-module.generator.js +3 -2
  5. package/dist/backend-actions/generators/actions-module.generator.js.map +1 -1
  6. package/dist/backend-actions/generators/authorization-policy-service.generator.d.ts +2 -0
  7. package/dist/backend-actions/generators/authorization-policy-service.generator.js +214 -0
  8. package/dist/backend-actions/generators/authorization-policy-service.generator.js.map +1 -0
  9. package/dist/backend-actions/generators/authorization-service.generator.d.ts +1 -1
  10. package/dist/backend-actions/generators/authorization-service.generator.js +20 -8
  11. package/dist/backend-actions/generators/authorization-service.generator.js.map +1 -1
  12. package/dist/backend-actions/generators/dispatcher-service.generator.js +3 -2
  13. package/dist/backend-actions/generators/dispatcher-service.generator.js.map +1 -1
  14. package/dist/backend-ai/generators/ai-route.generator.js +3 -3
  15. package/dist/backend-authentication/authentication.generator.js +23 -1
  16. package/dist/backend-authentication/authentication.generator.js.map +1 -1
  17. package/dist/backend-authentication/generators/auth-guard.generator.js +5 -8
  18. package/dist/backend-authentication/generators/auth-guard.generator.js.map +1 -1
  19. package/dist/backend-authentication/generators/authentication-module.generator.js +1 -1
  20. package/dist/backend-authentication/generators/authentication-service.generator.js +11 -8
  21. package/dist/backend-authentication/generators/authentication-service.generator.js.map +1 -1
  22. package/dist/backend-authentication/generators/authentication-types.generator.js +4 -3
  23. package/dist/backend-authentication/generators/authentication-types.generator.js.map +1 -1
  24. package/dist/backend-authentication/template/src/authentication.config.ts +9 -0
  25. package/dist/backend-authentication/template/src/authentication.mock.service.ts +77 -13
  26. package/dist/backend-authentication/template/src/utils.ts +45 -0
  27. package/dist/backend-core/backend.generator.js +16 -0
  28. package/dist/backend-core/backend.generator.js.map +1 -1
  29. package/dist/backend-core/generators/api-config.generator.js +5 -0
  30. package/dist/backend-core/generators/api-config.generator.js.map +1 -1
  31. package/dist/backend-core/types.d.ts +4 -0
  32. package/dist/backend-excel-io/generators/excel-io-service.generator.js +27 -11
  33. package/dist/backend-excel-io/generators/excel-io-service.generator.js.map +1 -1
  34. package/dist/backend-excel-io/template/excel-io.controller.ts +3 -3
  35. package/dist/backend-rest-api/generators/model-controller.generator.js +9 -5
  36. package/dist/backend-rest-api/generators/model-controller.generator.js.map +1 -1
  37. package/dist/backend-rest-api/template/restApi/src/restApi.utils.ts +9 -0
  38. package/dist/backend-router-trpc/generators/audit-log-route.generator.js +2 -2
  39. package/dist/backend-router-trpc/generators/excel-io-route.generator.js +1 -1
  40. package/dist/backend-router-trpc/generators/middleware.generator.js +8 -5
  41. package/dist/backend-router-trpc/generators/middleware.generator.js.map +1 -1
  42. package/dist/backend-router-trpc/generators/model-routes.generator.js +27 -7
  43. package/dist/backend-router-trpc/generators/model-routes.generator.js.map +1 -1
  44. package/dist/backend-router-trpc/generators/trpc-plugin.generator.js +9 -6
  45. package/dist/backend-router-trpc/generators/trpc-plugin.generator.js.map +1 -1
  46. package/dist/backend-router-trpc/generators/trpc-shared.generator.js +4 -24
  47. package/dist/backend-router-trpc/generators/trpc-shared.generator.js.map +1 -1
  48. package/dist/backend-router-trpc/router-trpc.generator.d.ts +4 -0
  49. package/dist/backend-router-trpc/router-trpc.generator.js +1 -0
  50. package/dist/backend-router-trpc/router-trpc.generator.js.map +1 -1
  51. package/dist/backend-router-trpc/template/viewer.router.ts +1 -6
  52. package/dist/backend-update/update-actions.decoders.d.ts +4 -4
  53. package/dist/backend-upload/template/src/upload.controller.ts +1 -1
  54. package/dist/backend-upload/template/src/upload.service.ts +11 -5
  55. package/dist/backend-view/model-view-service.generator.js +105 -52
  56. package/dist/backend-view/model-view-service.generator.js.map +1 -1
  57. package/dist/backend-view/view.generator.d.ts +2 -1
  58. package/dist/backend-view/view.generator.js +8 -1
  59. package/dist/backend-view/view.generator.js.map +1 -1
  60. package/dist/base/base.generator.js +2 -0
  61. package/dist/base/base.generator.js.map +1 -1
  62. package/dist/e2e/template/e2e/specs/example.spec.ts-snapshots/Navigate-to-homepage-and-take-snapshot-1-chromium-linux.png +0 -0
  63. package/dist/frontend-admin/admin.generator.js +2 -0
  64. package/dist/frontend-admin/admin.generator.js.map +1 -1
  65. package/dist/frontend-admin/generators/authorization-utils.generator.d.ts +1 -0
  66. package/dist/frontend-admin/generators/authorization-utils.generator.js +20 -0
  67. package/dist/frontend-admin/generators/authorization-utils.generator.js.map +1 -0
  68. package/dist/frontend-admin/generators/comment-sidebar.generator.js +9 -1
  69. package/dist/frontend-admin/generators/comment-sidebar.generator.js.map +1 -1
  70. package/dist/frontend-admin/generators/model-admin-page.generator.js +347 -184
  71. package/dist/frontend-admin/generators/model-admin-page.generator.js.map +1 -1
  72. package/dist/frontend-core/frontend.generator.d.ts +6 -0
  73. package/dist/frontend-core/frontend.generator.js +10 -3
  74. package/dist/frontend-core/frontend.generator.js.map +1 -1
  75. package/dist/frontend-core/template/README.md +2 -0
  76. package/dist/frontend-core/template/src/context-providers/auth-context-provider.tsx +1 -2
  77. package/dist/frontend-core/template/src/pages/dashboard/dashboard.page.tsx +10 -1
  78. package/dist/frontend-core/template/src/pages/login/login.page.tsx +1 -1
  79. package/dist/frontend-core/template/vite.config.ts +5 -0
  80. package/dist/frontend-core/types/component.d.ts +1 -1
  81. package/dist/frontend-core/types/contextprovider.d.ts +1 -1
  82. package/dist/frontend-core/types/hook.d.ts +1 -1
  83. package/dist/frontend-trpc-client/generators/model-hook.generator.js +104 -39
  84. package/dist/frontend-trpc-client/generators/model-hook.generator.js.map +1 -1
  85. package/dist/frontend-trpc-client/trpc-client.generator.js +28 -14
  86. package/dist/frontend-trpc-client/trpc-client.generator.js.map +1 -1
  87. package/dist/types/types.generator.d.ts +7 -0
  88. package/dist/types/types.generator.js +80 -0
  89. package/dist/types/types.generator.js.map +1 -1
  90. package/package.json +3 -3
@@ -184,6 +184,14 @@ export function ${context.admin.components.commentSidebar.name}({ model, filtere
184
184
  }, [currentUserId, createComment, model, commentTarget])
185
185
 
186
186
  const selectedLabel = selectedEntityId ? labelMap?.[selectedEntityId] ?? selectedEntityId : undefined
187
+ let commentListOnCreate: ((text: string) => void) | undefined
188
+ if (currentUserId) {
189
+ if (singleCommentThread && groups.length === 1) {
190
+ commentListOnCreate = (text: string) => handleCreate(text, groups[0].groupId)
191
+ } else {
192
+ commentListOnCreate = handleCreate
193
+ }
194
+ }
187
195
 
188
196
  if (!commentsLoaded) {
189
197
  return (
@@ -237,7 +245,7 @@ export function ${context.admin.components.commentSidebar.name}({ model, filtere
237
245
  comments={singleCommentThread && groups.length === 1 ? groups[0].comments : groups}
238
246
  onResolve={handleResolve}
239
247
  onReply={currentUserId ? handleReply : undefined}
240
- onCreate={singleCommentThread && groups.length === 1 ? (currentUserId ? (text: string) => handleCreate(text, groups[0].groupId) : undefined) : (currentUserId ? handleCreate : undefined)}
248
+ onCreate={commentListOnCreate}
241
249
  />
242
250
  )}
243
251
  </div>
@@ -1 +1 @@
1
- {"version":3,"file":"comment-sidebar.generator.js","sourceRoot":"","sources":["../../../src/frontend-admin/generators/comment-sidebar.generator.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AASA,wDA+MC;AAxND,6DAA8C;AAI9C;;;;GAIG;AACH,SAAgB,sBAAsB,CAAC,EAAE,OAAO,EAA8B;IAC5E,MAAM,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,SAAgB,CAAE,CAAA;IAC1D,MAAM,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,MAAa,CAAE,CAAA;IAEpD,MAAM,OAAO,GAAG,SAAS,CAAC,eAAe;QACvC,EAAE;SACD,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,cAAe,CAAC,QAAQ,CAAC;SACvD,SAAS,CAAC;QACT,IAAI,EAAE,SAAS,CAAC,aAAa,CAAC,OAAO,CAAC;QACtC,KAAK,EAAE;YACL,SAAS,CAAC,cAAc,CAAC,UAAU,CAAC;YACpC,SAAS,CAAC,cAAc,CAAC,SAAS,CAAC;YACnC,SAAS,CAAC,cAAc,CAAC,aAAa,CAAC;YACvC,SAAS,CAAC,cAAc,CAAC,WAAW,CAAC;SACtC;KACF,CAAC;SACD,GAAG,CAAC,YAAY,CAAC,SAAS,CAAC;SAC3B,GAAG,CAAC,SAAS,CAAC,SAAS,CAAC;SACxB,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;SAC9B,OAAO,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;IAE9B,MAAM,cAAc,GAAG,SAAS,CAAC,UAAU,CAAC,IAAI,CAAA;IAEhD,OAAO;EACP,OAAO,CAAC,QAAQ,EAAE;;;;;;;;;;;;;;kBAcF,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,cAAe,CAAC,IAAI;;;;;;MAMzD,YAAY,CAAC,SAAS,CAAC,YAAY,CAAC,IAAI;MACxC,YAAY,CAAC,SAAS,CAAC,MAAO,CAAC,IAAI;MACnC,YAAY,CAAC,SAAS,CAAC,WAAY,CAAC,IAAI;MACxC,YAAY,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI;QAClC,YAAY,CAAC,SAAS,CAAC,IAAI;;;;;;;;;YASvB,SAAS,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,iBAAiB,SAAS,CAAC,SAAS,CAAC,IAAI;;;;;;;;;;0BAUvD,cAAc;;;;yDAIiB,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iDAiCtB,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI;;;;;;;;iCAQ1C,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI;;;;sCAIlB,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI;;;;;;;;iCAQ/B,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI;;;;;;;;;;;;iCAYvB,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsEvD,CAAA;AACD,CAAC"}
1
+ {"version":3,"file":"comment-sidebar.generator.js","sourceRoot":"","sources":["../../../src/frontend-admin/generators/comment-sidebar.generator.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AASA,wDAuNC;AAhOD,6DAA8C;AAI9C;;;;GAIG;AACH,SAAgB,sBAAsB,CAAC,EAAE,OAAO,EAA8B;IAC5E,MAAM,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,SAAgB,CAAE,CAAA;IAC1D,MAAM,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,MAAa,CAAE,CAAA;IAEpD,MAAM,OAAO,GAAG,SAAS,CAAC,eAAe;QACvC,EAAE;SACD,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,cAAe,CAAC,QAAQ,CAAC;SACvD,SAAS,CAAC;QACT,IAAI,EAAE,SAAS,CAAC,aAAa,CAAC,OAAO,CAAC;QACtC,KAAK,EAAE;YACL,SAAS,CAAC,cAAc,CAAC,UAAU,CAAC;YACpC,SAAS,CAAC,cAAc,CAAC,SAAS,CAAC;YACnC,SAAS,CAAC,cAAc,CAAC,aAAa,CAAC;YACvC,SAAS,CAAC,cAAc,CAAC,WAAW,CAAC;SACtC;KACF,CAAC;SACD,GAAG,CAAC,YAAY,CAAC,SAAS,CAAC;SAC3B,GAAG,CAAC,SAAS,CAAC,SAAS,CAAC;SACxB,OAAO,CAAC,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;SAC9B,OAAO,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;IAE9B,MAAM,cAAc,GAAG,SAAS,CAAC,UAAU,CAAC,IAAI,CAAA;IAEhD,OAAO;EACP,OAAO,CAAC,QAAQ,EAAE;;;;;;;;;;;;;;kBAcF,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,cAAe,CAAC,IAAI;;;;;;MAMzD,YAAY,CAAC,SAAS,CAAC,YAAY,CAAC,IAAI;MACxC,YAAY,CAAC,SAAS,CAAC,MAAO,CAAC,IAAI;MACnC,YAAY,CAAC,SAAS,CAAC,WAAY,CAAC,IAAI;MACxC,YAAY,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI;QAClC,YAAY,CAAC,SAAS,CAAC,IAAI;;;;;;;;;YASvB,SAAS,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,iBAAiB,SAAS,CAAC,SAAS,CAAC,IAAI;;;;;;;;;;0BAUvD,cAAc;;;;yDAIiB,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iDAiCtB,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI;;;;;;;;iCAQ1C,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI;;;;sCAIlB,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI;;;;;;;;iCAQ/B,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI;;;;;;;;;;;;iCAYvB,SAAS,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8EvD,CAAA;AACD,CAAC"}
@@ -39,6 +39,8 @@ const Generator = __importStar(require("@postxl/generator"));
39
39
  * Generator function that generates the Admin page for a model
40
40
  */
41
41
  function generateModelAdminPage({ model, context, }) {
42
+ const adminUiPolicy = resolveAdminUiPolicy(model, context);
43
+ const writePolicy = resolveWritePolicy(model, context);
42
44
  const imports = Generator.ImportGenerator
43
45
  //
44
46
  .from(model.admin.page.pageComponent.location)
@@ -100,115 +102,45 @@ function generateModelAdminPage({ model, context, }) {
100
102
  ],
101
103
  });
102
104
  const showComments = model.name !== 'Comment' && !!context.admin.components.commentSidebar;
103
- if (showComments) {
104
- imports.add(context.admin.components.commentSidebar);
105
- imports.addImport({
106
- from: Generator.toPackageName('lucide-react'),
107
- items: [
108
- Generator.toFunctionName('ListTree'),
109
- Generator.toFunctionName('MessageSquareText'),
110
- Generator.toFunctionName('ScrollText'),
111
- Generator.toFunctionName('Sparkles'),
112
- ],
113
- });
114
- }
115
- else {
116
- imports.addImport({
117
- from: Generator.toPackageName('lucide-react'),
118
- items: [
119
- Generator.toFunctionName('ListTree'),
120
- Generator.toFunctionName('ScrollText'),
121
- Generator.toFunctionName('Sparkles'),
122
- ],
123
- });
124
- }
125
- const relatedModels = [];
126
- for (const relatedModelName of model.relatedModelNames) {
127
- const relatedModel = context.models.get(relatedModelName);
128
- imports.add(relatedModel.itemsHook);
129
- relatedModels.push({
130
- model: relatedModel,
131
- mapName: relatedModel.internalPluralName,
132
- mapDefinition: `const {${relatedModel.itemsHook.map.name}: ${relatedModel.internalPluralName}} = ${relatedModel.itemsHook.name}()`,
133
- });
134
- }
135
- let createModalHook = '';
136
- let createModalIntentHook = '';
137
- let createModal = '';
138
- if (!model.isReadonly) {
139
- imports
140
- //
141
- .addType(model.types.id)
142
- .addImport({ from: Generator.toPackageName('react'), items: [Generator.toFunctionName('useRef')] })
143
- .addImport({ from: Generator.toPackageName('sonner'), items: [Generator.toFunctionName('toast')] })
144
- .addImport({ from: model.forms.createModal.location, items: [model.forms.createModal.name] });
145
- // In case the model does not provide any explicit label field, we generally use the id field as fallback.
146
- // However, in case of creating a new item, we do not have an Id yet and hence cannot show it in success and error messages.
147
- const hasLabelField = model.labelField.name !== model.idField.name;
148
- const variableName = hasLabelField ? `created${model.name}` : '_';
149
- const label = hasLabelField ? ` "\${${variableName}.${model.labelField.name}}"` : '';
150
- createModalHook = ` const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)`;
151
- createModalIntentHook = `
152
- useEffect(() => {
153
- if (typeof globalThis === 'undefined') {
154
- return
155
- }
156
-
157
- const storageKey = 'pxl:create-intent-model'
158
- const targetModel = '${model.name}'
159
- const openFromIntent = (intentModel: unknown) => {
160
- if (intentModel === targetModel) {
161
- setIsCreateModalOpen(true)
162
- globalThis.sessionStorage.removeItem(storageKey)
163
- }
164
- }
165
-
166
- openFromIntent(globalThis.sessionStorage.getItem(storageKey))
167
-
168
- const onIntent = (event: Event) => {
169
- const detail = (event as CustomEvent<{ model?: string }>).detail
170
- openFromIntent(detail?.model)
171
- }
172
-
173
- globalThis.addEventListener('pxl:create-intent', onIntent)
174
- return () => {
175
- globalThis.removeEventListener('pxl:create-intent', onIntent)
176
- }
177
- }, [])`;
178
- createModal = `
179
- <${model.forms.createModal.name}
180
- open={isCreateModalOpen}
181
- onClose={() => setIsCreateModalOpen(false)}
182
- onSuccess={(${variableName}) => {
183
- toast.success(\`${model.userFriendlyName}${label} created successfully!\`)
184
- setIsCreateModalOpen(false)
185
- }}
186
- onError={(${variableName}, error) => {
187
- toast.error(\`Error creating ${model.userFriendlyName}${label}: \${String(error)}\`)
188
- }}
189
- />`;
190
- }
105
+ configureSidebarImports({ imports, context, showComments });
106
+ const relatedModels = collectRelatedModels({ model, context, imports });
107
+ const createModalContent = resolveCreateModalContent(model, imports);
108
+ const canAccessAdminExpression = toRoleExpression(adminUiPolicy, 'userRoles');
109
+ const canWriteModelExpression = toRoleExpression(writePolicy, 'userRoles');
110
+ const commentCountState = buildCommentCountState(showComments);
111
+ const modelHookMutationFields = buildModelHookMutationFields(model);
112
+ const commentSidebarLogic = buildCommentSidebarLogic({ showComments, model });
113
+ const commentSelectedEntityId = buildCommentSelectedEntityId(showComments);
114
+ const cellUpdateHandler = buildCellUpdateHandler(model);
115
+ const removeRowsHandler = buildRemoveRowsHandler(model);
116
+ const commentSidebarTab = buildCommentSidebarTab({ showComments, model });
117
+ const relatedTableProps = buildRelatedTableProps(relatedModels);
118
+ const addRowProp = buildAddRowProp(model);
119
+ const cellUpdateProp = buildCellUpdateProp(model);
120
+ const removeRowsProp = buildRemoveRowsProp(model);
191
121
  return `
192
- import { SidebarInset, SidebarTab, useDebouncedCallback } from '@postxl/ui-components'
122
+ import { SidebarTab, useDebouncedCallback } from '@postxl/ui-components'
193
123
  import { APP_CONFIG } from '@lib/config'
194
124
  import { useAuth } from '@context-providers/auth-context-provider'
125
+ import { hasAnyRole } from '@lib/authorization'
195
126
  ${imports.generate()}
196
127
 
197
128
  export default function ${model.admin.page.pageComponent.name}() {
198
129
  const navigate = useNavigate()
199
130
  const { viewerData } = useAuth()
200
131
  const currentUserId = viewerData?.user?.id
201
- const isAdmin = viewerData?.userRoles?.includes('admin') ?? false
202
- ${showComments ? 'const [commentCount, setCommentCount] = useState(0)' : ''}
132
+ const userRoles = viewerData?.userRoles ?? []
133
+ const canAccessAdminPage = ${canAccessAdminExpression}
134
+ const canWriteModel = ${canWriteModelExpression}
135
+ ${commentCountState}
203
136
 
204
137
  const { selectedKey, focusedCell } = useSearch({
205
138
  from: '/_auth-routes${model.admin.page.route}',
206
139
  select: (search) => ({ selectedKey: search.selectedKey, focusedCell: search.focusedCell }),
207
140
  })
208
-
209
- ${createModalHook}
210
- ${createModalIntentHook}
211
-
141
+
142
+ ${createModalContent.hook}
143
+ ${createModalContent.intentHook}
212
144
  // Track selected row IDs from checkbox selection
213
145
  const [selectedRowIds, setSelectedRowIds] = useState<string[]>([])
214
146
 
@@ -226,7 +158,7 @@ export default function ${model.admin.page.pageComponent.name}() {
226
158
  hasNextPage,
227
159
  fetchNextPage,
228
160
  total,
229
- ${model.isReadonly ? '' : `${model.itemsHook.updateField.name}: updateFieldMutation, ${model.itemsHook.updateFieldMany.name}: updateFieldManyMutation, ${model.itemsHook.deleteMany.name}: deleteManyMutation,`}
161
+ ${modelHookMutationFields}
230
162
  } = ${model.itemsHook.name}({ filters, sort: sortState })
231
163
 
232
164
  // Queries for related models' maps used by the table (passed as separate props)
@@ -244,18 +176,7 @@ export default function ${model.admin.page.pageComponent.name}() {
244
176
  return focusedCell
245
177
  }, [focusedCell])
246
178
 
247
- ${showComments
248
- ? `// Comment sidebar: compute filtered entity IDs and label map from table data
249
- const commentFilteredIds = useMemo(() =>
250
- filteredDataList?.map(item => String(item.id)) ?? [],
251
- [filteredDataList]
252
- )
253
- const commentLabelMap = useMemo(() =>
254
- Object.fromEntries((filteredDataList ?? []).map(item => [String(item.id), String(item.${model.labelField.name})])),
255
- [filteredDataList]
256
- )
257
- `
258
- : ''}
179
+ ${commentSidebarLogic}
259
180
  // Callback when row selection changes (checkbox)
260
181
  const handleRowSelectionChange = useCallback((rowIds: string[]) => {
261
182
  setSelectedRowIds(rowIds)
@@ -298,7 +219,7 @@ export default function ${model.admin.page.pageComponent.name}() {
298
219
  // Callback when cell focus changes in the table — also sets selectedKey from the focused row
299
220
  const handleCellChange = useCallback((args: { rowIndex: number; columnId: string }) => {
300
221
  const row = filteredDataList?.[args.rowIndex]
301
- const key = row?.${model.keyField.name} != null ? String(row.${model.keyField.name}) : undefined
222
+ const key = row?.${model.keyField.name} == null ? undefined : String(row.${model.keyField.name})
302
223
  void navigate({ to: '${model.admin.page.route}', search: { selectedKey: key, focusedCell: args.columnId || undefined } })
303
224
  // Clear checkbox selection when focusing a cell
304
225
  setSelectedRowIds([])
@@ -306,6 +227,18 @@ export default function ${model.admin.page.pageComponent.name}() {
306
227
 
307
228
  useEffect(() => {
308
229
  const prefix = 'admin-${model._conjugated.kebabCase}-filter'
230
+ const getFilterInputSchema = (valueType: string) => {
231
+ if (valueType === 'number') {
232
+ return numberFilterInputSchema
233
+ }
234
+ if (valueType === 'date') {
235
+ return dateFilterInputSchema
236
+ }
237
+ if (valueType === 'boolean') {
238
+ return booleanFilterInputSchema
239
+ }
240
+ return stringFilterInputSchema
241
+ }
309
242
 
310
243
  const filterActions = Object.entries(${model.types.filter.config.name}).map(([field, fieldConfig]) =>
311
244
  input({
@@ -313,14 +246,7 @@ export default function ${model.admin.page.pageComponent.name}() {
313
246
  visibility: 'visible',
314
247
  group: 'Filter records by...',
315
248
  label: toFilterActionLabel(field),
316
- inputSchema:
317
- fieldConfig.valueType === 'number'
318
- ? numberFilterInputSchema
319
- : fieldConfig.valueType === 'date'
320
- ? dateFilterInputSchema
321
- : fieldConfig.valueType === 'boolean'
322
- ? booleanFilterInputSchema
323
- : stringFilterInputSchema,
249
+ inputSchema: getFilterInputSchema(fieldConfig.valueType),
324
250
  run: async (query, inputValue) => {
325
251
  if (fieldConfig.valueType === 'number') {
326
252
  const parsed = numberFilterInputSchema.safeParse(inputValue)
@@ -414,48 +340,15 @@ export default function ${model.admin.page.pageComponent.name}() {
414
340
  setSearchInput,
415
341
  ])
416
342
 
417
- ${model.isReadonly
418
- ? ''
419
- : `// Batch cell updates: collect updates within the same microtask and send as one updateFieldMany
420
- const pendingUpdatesRef = useRef<Map<string, { field: string; value: unknown; ids: ${model.types.id.name}[] }> | null>(null)
421
-
422
- const handleCellUpdate = useCallback((args: { rowIndex: number; columnId: string; value: unknown }) => {
423
- const row = filteredDataList?.[args.rowIndex]
424
- if (!row) return
425
-
426
- // Initialize batch map and schedule flush on first call in this microtask
427
- if (!pendingUpdatesRef.current) {
428
- pendingUpdatesRef.current = new Map()
429
- queueMicrotask(() => {
430
- const pending = pendingUpdatesRef.current
431
- pendingUpdatesRef.current = null
432
- if (!pending) return
343
+ ${commentSelectedEntityId}
433
344
 
434
- for (const entry of pending.values()) {
435
- if (entry.ids.length === 1) {
436
- void updateFieldMutation({ id: entry.ids[0], field: entry.field, value: entry.value } as Parameters<typeof updateFieldMutation>[0])
437
- } else {
438
- void updateFieldManyMutation({ ids: entry.ids, field: entry.field, value: entry.value } as Parameters<typeof updateFieldManyMutation>[0])
439
- }
440
- }
441
- })
442
- }
345
+ ${cellUpdateHandler}
443
346
 
444
- // Group by field+value so identical field updates across rows get batched
445
- const key = \`\${args.columnId}::\${JSON.stringify(args.value)}\`
446
- const existing = pendingUpdatesRef.current.get(key)
447
- if (existing) {
448
- existing.ids.push(row.id)
449
- } else {
450
- pendingUpdatesRef.current.set(key, { field: args.columnId, value: args.value, ids: [row.id] })
451
- }
452
- }, [filteredDataList, updateFieldMutation, updateFieldManyMutation])
453
-
454
- function handleRemoveRows(rows: ${model.types.id.name}[]) {
455
- void deleteManyMutation(rows)
347
+ ${removeRowsHandler}
348
+
349
+ if (!canAccessAdminPage) {
350
+ return null
456
351
  }
457
- `}
458
-
459
352
 
460
353
  return (
461
354
  <>
@@ -467,24 +360,7 @@ export default function ${model.admin.page.pageComponent.name}() {
467
360
  >
468
361
  <AdminSidebarNavContent />
469
362
  </SidebarTab>
470
- ${showComments
471
- ? `<SidebarTab
472
- side="right"
473
- id="comment-content"
474
- icon={MessageSquareText}
475
- label="Comment"
476
- order={0}
477
- badge={{ label: String(commentCount) }}
478
- >
479
- <ModelCommentContent
480
- model="${model.name}"
481
- filteredIds={commentFilteredIds}
482
- labelMap={commentLabelMap}
483
- selectedEntityId={selectedEntityId ? String(selectedEntityId) : selectedRowIds.length === 1 ? selectedRowIds[0] : undefined}
484
- onCommentCountChange={setCommentCount}
485
- />
486
- </SidebarTab>`
487
- : ''}
363
+ ${commentSidebarTab}
488
364
  <SidebarTab
489
365
  side="right"
490
366
  id="audit-log-content"
@@ -514,7 +390,7 @@ export default function ${model.admin.page.pageComponent.name}() {
514
390
  <AiSidebarContent />
515
391
  </SidebarTab>
516
392
  )}
517
- <SidebarInset className="p-4 overflow-hidden gap-4">
393
+ <div className="p-4 overflow-hidden gap-4">
518
394
  <h3 className="font-bold text-[1.75rem]">${model.userFriendlyName}</h3>
519
395
  <${model.table.component.name}
520
396
  data={filteredDataList ?? []}
@@ -534,11 +410,9 @@ export default function ${model.admin.page.pageComponent.name}() {
534
410
  onSearchInputChange={handleSearchInputChange}
535
411
  sort={sortState}
536
412
  onSortChange={setSortState}
537
- ${model.isReadonly
538
- ? ''
539
- : `onCellUpdate={handleCellUpdate}
540
- handleAddRow={() => setIsCreateModalOpen(true)}
541
- handleRemoveRows={handleRemoveRows}`}
413
+ ${cellUpdateProp}
414
+ ${addRowProp}
415
+ ${removeRowsProp}
542
416
  onCellChange={handleCellChange}
543
417
  onRowSelectionChange={handleRowSelectionChange}
544
418
  selectedKey={selectedKey}
@@ -548,17 +422,306 @@ export default function ${model.admin.page.pageComponent.name}() {
548
422
  total={total}
549
423
  model="${model._conjugated.camelCase}"
550
424
  currentUserId={currentUserId}
551
- isAdmin={isAdmin}
425
+ isAdmin={canWriteModel}
552
426
  onFiltersReplace={setFilters}
553
427
  onSortReplace={setSortState}
554
- ${relatedModels.map((rm) => `${rm.model.table.propName}={${rm.mapName}}`).join('\n ')}
428
+ ${relatedTableProps}
555
429
  />
556
- </SidebarInset>
430
+ </div>
557
431
 
558
- ${createModal}
432
+ ${createModalContent.modal}
559
433
  </>
560
434
  )
561
435
  }
562
436
  `;
563
437
  }
438
+ function configureSidebarImports({ imports, context, showComments, }) {
439
+ if (showComments) {
440
+ imports.add(context.admin.components.commentSidebar);
441
+ imports.addImport({
442
+ from: Generator.toPackageName('lucide-react'),
443
+ items: [
444
+ Generator.toFunctionName('ListTree'),
445
+ Generator.toFunctionName('MessageSquareText'),
446
+ Generator.toFunctionName('ScrollText'),
447
+ Generator.toFunctionName('Sparkles'),
448
+ ],
449
+ });
450
+ return;
451
+ }
452
+ imports.addImport({
453
+ from: Generator.toPackageName('lucide-react'),
454
+ items: [
455
+ Generator.toFunctionName('ListTree'),
456
+ Generator.toFunctionName('ScrollText'),
457
+ Generator.toFunctionName('Sparkles'),
458
+ ],
459
+ });
460
+ }
461
+ function collectRelatedModels({ model, context, imports, }) {
462
+ const relatedModels = [];
463
+ for (const relatedModelName of model.relatedModelNames) {
464
+ const relatedModel = context.models.get(relatedModelName);
465
+ imports.add(relatedModel.itemsHook);
466
+ relatedModels.push({
467
+ model: relatedModel,
468
+ mapName: relatedModel.internalPluralName,
469
+ mapDefinition: `const {${relatedModel.itemsHook.map.name}: ${relatedModel.internalPluralName}} = ${relatedModel.itemsHook.name}()`,
470
+ });
471
+ }
472
+ return relatedModels;
473
+ }
474
+ function toRoleExpression(policy, userRolesVariableName) {
475
+ if (policy.allowAll) {
476
+ return 'true';
477
+ }
478
+ return `hasAnyRole(${userRolesVariableName}, ${JSON.stringify(policy.roles)})`;
479
+ }
480
+ function buildCommentCountState(showComments) {
481
+ if (!showComments) {
482
+ return '';
483
+ }
484
+ return 'const [commentCount, setCommentCount] = useState(0)';
485
+ }
486
+ function buildModelHookMutationFields(model) {
487
+ const fields = [];
488
+ if (model.itemsHook.updateField) {
489
+ fields.push(`${model.itemsHook.updateField.name}: updateFieldMutation,`);
490
+ }
491
+ if (model.itemsHook.updateFieldMany) {
492
+ fields.push(`${model.itemsHook.updateFieldMany.name}: updateFieldManyMutation,`);
493
+ }
494
+ if (model.itemsHook.deleteMany) {
495
+ fields.push(`${model.itemsHook.deleteMany.name}: deleteManyMutation,`);
496
+ }
497
+ return fields.join('\n ');
498
+ }
499
+ function buildCommentSidebarLogic({ showComments, model, }) {
500
+ if (!showComments) {
501
+ return '';
502
+ }
503
+ return `// Comment sidebar: compute filtered entity IDs and label map from table data
504
+ const commentFilteredIds = useMemo(() =>
505
+ filteredDataList?.map(item => String(item.id)) ?? [],
506
+ [filteredDataList]
507
+ )
508
+ const commentLabelMap = useMemo(() =>
509
+ Object.fromEntries((filteredDataList ?? []).map(item => [String(item.id), String(item.${model.labelField.name})])),
510
+ [filteredDataList]
511
+ )`;
512
+ }
513
+ function buildCommentSelectedEntityId(showComments) {
514
+ if (!showComments) {
515
+ return '';
516
+ }
517
+ return `
518
+ let commentSelectedEntityId: string | undefined
519
+ if (selectedEntityId) {
520
+ commentSelectedEntityId = String(selectedEntityId)
521
+ } else if (selectedRowIds.length === 1) {
522
+ commentSelectedEntityId = selectedRowIds[0]
523
+ }`;
524
+ }
525
+ function buildCellUpdateHandler(model) {
526
+ if (!model.itemsHook.updateField) {
527
+ return '';
528
+ }
529
+ const flushMutations = model.itemsHook.updateFieldMany
530
+ ? `if (entry.ids.length === 1) {
531
+ void updateFieldMutation({ id: entry.ids[0], field: entry.field, value: entry.value } as Parameters<typeof updateFieldMutation>[0])
532
+ } else {
533
+ void updateFieldManyMutation({ ids: entry.ids, field: entry.field, value: entry.value } as Parameters<typeof updateFieldManyMutation>[0])
534
+ }`
535
+ : `for (const id of entry.ids) {
536
+ void updateFieldMutation({ id, field: entry.field, value: entry.value } as Parameters<typeof updateFieldMutation>[0])
537
+ }`;
538
+ const dependencies = model.itemsHook.updateFieldMany
539
+ ? '[filteredDataList, updateFieldMutation, updateFieldManyMutation]'
540
+ : '[filteredDataList, updateFieldMutation]';
541
+ return `// Batch cell updates: collect updates within the same microtask and send as one updateFieldMany
542
+ const pendingUpdatesRef = useRef<Map<string, { field: string; value: unknown; ids: ${model.types.id.name}[] }> | null>(null)
543
+
544
+ const handleCellUpdate = useCallback((args: { rowIndex: number; columnId: string; value: unknown }) => {
545
+ const row = filteredDataList?.[args.rowIndex]
546
+ if (!row) return
547
+
548
+ // Initialize batch map and schedule flush on first call in this microtask
549
+ if (!pendingUpdatesRef.current) {
550
+ pendingUpdatesRef.current = new Map()
551
+ queueMicrotask(() => {
552
+ const pending = pendingUpdatesRef.current
553
+ pendingUpdatesRef.current = null
554
+ if (!pending) return
555
+
556
+ for (const entry of pending.values()) {
557
+ ${flushMutations}
558
+ }
559
+ })
560
+ }
561
+
562
+ // Group by field+value so identical field updates across rows get batched
563
+ const key = \`\${args.columnId}::\${JSON.stringify(args.value)}\`
564
+ const existing = pendingUpdatesRef.current.get(key)
565
+ if (existing) {
566
+ existing.ids.push(row.id)
567
+ } else {
568
+ pendingUpdatesRef.current.set(key, { field: args.columnId, value: args.value, ids: [row.id] })
569
+ }
570
+ }, ${dependencies})`;
571
+ }
572
+ function buildRemoveRowsHandler(model) {
573
+ if (!model.itemsHook.deleteMany) {
574
+ return '';
575
+ }
576
+ return `function handleRemoveRows(rows: ${model.types.id.name}[]) {
577
+ void deleteManyMutation(rows)
578
+ }`;
579
+ }
580
+ function buildCommentSidebarTab({ showComments, model }) {
581
+ if (!showComments) {
582
+ return '';
583
+ }
584
+ return `<SidebarTab
585
+ side="right"
586
+ id="comment-content"
587
+ icon={MessageSquareText}
588
+ label="Comment"
589
+ order={0}
590
+ badge={{ label: String(commentCount) }}
591
+ >
592
+ <ModelCommentContent
593
+ model="${model.name}"
594
+ filteredIds={commentFilteredIds}
595
+ labelMap={commentLabelMap}
596
+ selectedEntityId={commentSelectedEntityId}
597
+ onCommentCountChange={setCommentCount}
598
+ />
599
+ </SidebarTab>`;
600
+ }
601
+ function buildRelatedTableProps(relatedModels) {
602
+ return relatedModels.map((rm) => `${rm.model.table.propName}={${rm.mapName}}`).join('\n ');
603
+ }
604
+ function buildAddRowProp(model) {
605
+ if (model.isReadonly) {
606
+ return '';
607
+ }
608
+ return 'handleAddRow={() => setIsCreateModalOpen(true)}';
609
+ }
610
+ function buildCellUpdateProp(model) {
611
+ if (!model.itemsHook.updateField) {
612
+ return '';
613
+ }
614
+ return 'onCellUpdate={handleCellUpdate}';
615
+ }
616
+ function buildRemoveRowsProp(model) {
617
+ if (!model.itemsHook.deleteMany) {
618
+ return '';
619
+ }
620
+ return 'handleRemoveRows={handleRemoveRows}';
621
+ }
622
+ function resolveCreateModalContent(model, imports) {
623
+ if (!model.isReadonly) {
624
+ imports
625
+ .addType(model.types.id)
626
+ .addImport({ from: Generator.toPackageName('sonner'), items: [Generator.toFunctionName('toast')] })
627
+ .addImport({ from: model.forms.createModal.location, items: [model.forms.createModal.name] });
628
+ if (model.itemsHook.updateField) {
629
+ imports.addImport({ from: Generator.toPackageName('react'), items: [Generator.toFunctionName('useRef')] });
630
+ }
631
+ // In case the model does not provide any explicit label field, we generally use the id field as fallback.
632
+ // However, in case of creating a new item, we do not have an Id yet and hence cannot show it in success and error messages.
633
+ const hasLabelField = model.labelField.name !== model.idField.name;
634
+ const variableName = hasLabelField ? `created${model.name}` : '_';
635
+ const label = hasLabelField ? ` "\${${variableName}.${model.labelField.name}}"` : '';
636
+ return {
637
+ hook: ` const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)`,
638
+ intentHook: `
639
+ useEffect(() => {
640
+ if (typeof globalThis === 'undefined') {
641
+ return
642
+ }
643
+
644
+ const storageKey = 'pxl:create-intent-model'
645
+ const targetModel = '${model.name}'
646
+ const openFromIntent = (intentModel: unknown) => {
647
+ if (intentModel === targetModel) {
648
+ setIsCreateModalOpen(true)
649
+ globalThis.sessionStorage.removeItem(storageKey)
650
+ }
651
+ }
652
+
653
+ openFromIntent(globalThis.sessionStorage.getItem(storageKey))
654
+
655
+ const onIntent = (event: Event) => {
656
+ const detail = (event as CustomEvent<{ model?: string }>).detail
657
+ openFromIntent(detail?.model)
658
+ }
659
+
660
+ globalThis.addEventListener('pxl:create-intent', onIntent)
661
+ return () => {
662
+ globalThis.removeEventListener('pxl:create-intent', onIntent)
663
+ }
664
+ }, [])`,
665
+ modal: `
666
+ <${model.forms.createModal.name}
667
+ open={isCreateModalOpen}
668
+ onClose={() => setIsCreateModalOpen(false)}
669
+ onSuccess={(${variableName}) => {
670
+ toast.success(\`${model.userFriendlyName}${label} created successfully!\`)
671
+ setIsCreateModalOpen(false)
672
+ }}
673
+ onError={(${variableName}, error) => {
674
+ toast.error(\`Error creating ${model.userFriendlyName}${label}: \${String(error)}\`)
675
+ }}
676
+ />`,
677
+ };
678
+ }
679
+ return { hook: '', intentHook: '', modal: '' };
680
+ }
681
+ function resolveSchemaRuleSet(model, context) {
682
+ for (const [schemaName, schemaRules] of context.schema.schemaAuth.entries()) {
683
+ if (schemaName === model.databaseSchema) {
684
+ return (schemaRules ?? {});
685
+ }
686
+ }
687
+ return {};
688
+ }
689
+ function resolveEffectiveRuleSet(model, context) {
690
+ const schemaRuleSet = resolveSchemaRuleSet(model, context);
691
+ const modelRuleSet = (model.auth ?? {});
692
+ return { ...schemaRuleSet, ...modelRuleSet };
693
+ }
694
+ function resolveWritePolicy(model, context) {
695
+ const effectiveRules = resolveEffectiveRuleSet(model, context);
696
+ const roles = effectiveRules.write?.anyRole;
697
+ if (roles) {
698
+ return { allowAll: false, roles };
699
+ }
700
+ // If create/update/delete are configured independently, use a conservative write gate:
701
+ // users must satisfy all configured operation role sets to see generic write UI controls.
702
+ const operationRoleSets = [
703
+ effectiveRules.create?.anyRole,
704
+ effectiveRules.update?.anyRole,
705
+ effectiveRules.delete?.anyRole,
706
+ ]
707
+ .filter((entry) => !!entry && entry.length > 0)
708
+ .map((entry) => Array.from(new Set(entry)));
709
+ if (operationRoleSets.length > 0) {
710
+ const [firstRoleSet, ...remainingRoleSets] = operationRoleSets;
711
+ if (!firstRoleSet) {
712
+ return { allowAll: !(context.schema.auth?.defaultDeny ?? true), roles: [] };
713
+ }
714
+ const intersection = remainingRoleSets.reduce((shared, current) => shared.filter((role) => current.includes(role)), firstRoleSet);
715
+ return { allowAll: false, roles: intersection };
716
+ }
717
+ return { allowAll: !(context.schema.auth?.defaultDeny ?? true), roles: [] };
718
+ }
719
+ function resolveAdminUiPolicy(model, context) {
720
+ const effectiveRules = resolveEffectiveRuleSet(model, context);
721
+ const roles = effectiveRules.adminUi?.visibleFor;
722
+ if (roles) {
723
+ return { allowAll: false, roles };
724
+ }
725
+ return { allowAll: !(context.schema.auth?.defaultDeny ?? true), roles: [] };
726
+ }
564
727
  //# sourceMappingURL=model-admin-page.generator.js.map