@postxl/generators 1.17.2 → 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 (107) 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-actions/actions.generator.js +1 -20
  64. package/dist/frontend-actions/actions.generator.js.map +1 -1
  65. package/dist/frontend-admin/admin.generator.js +4 -2
  66. package/dist/frontend-admin/admin.generator.js.map +1 -1
  67. package/dist/frontend-admin/generators/admin-sidebar.generator.d.ts +2 -1
  68. package/dist/frontend-admin/generators/admin-sidebar.generator.js +8 -26
  69. package/dist/frontend-admin/generators/admin-sidebar.generator.js.map +1 -1
  70. package/dist/frontend-admin/generators/authorization-utils.generator.d.ts +1 -0
  71. package/dist/frontend-admin/generators/authorization-utils.generator.js +20 -0
  72. package/dist/frontend-admin/generators/authorization-utils.generator.js.map +1 -0
  73. package/dist/frontend-admin/generators/comment-sidebar.generator.js +9 -1
  74. package/dist/frontend-admin/generators/comment-sidebar.generator.js.map +1 -1
  75. package/dist/frontend-admin/generators/data-management-page.generator.js +14 -7
  76. package/dist/frontend-admin/generators/data-management-page.generator.js.map +1 -1
  77. package/dist/frontend-admin/generators/excel-io-page.generator.js +16 -9
  78. package/dist/frontend-admin/generators/excel-io-page.generator.js.map +1 -1
  79. package/dist/frontend-admin/generators/import-review-page.generator.js +16 -10
  80. package/dist/frontend-admin/generators/import-review-page.generator.js.map +1 -1
  81. package/dist/frontend-admin/generators/model-admin-page.generator.js +399 -187
  82. package/dist/frontend-admin/generators/model-admin-page.generator.js.map +1 -1
  83. package/dist/frontend-core/frontend.generator.d.ts +6 -0
  84. package/dist/frontend-core/frontend.generator.js +10 -3
  85. package/dist/frontend-core/frontend.generator.js.map +1 -1
  86. package/dist/frontend-core/template/README.md +2 -0
  87. package/dist/frontend-core/template/src/components/ui/application-header/application-header.tsx +44 -0
  88. package/dist/frontend-core/template/src/components/ui/color-mode-toggle/color-mode-toggle.tsx +1 -1
  89. package/dist/frontend-core/template/src/context-providers/auth-context-provider.tsx +1 -2
  90. package/dist/frontend-core/template/src/context-providers/header-context-provider.tsx +41 -0
  91. package/dist/frontend-core/template/src/pages/authorized-page-layout.tsx +49 -0
  92. package/dist/frontend-core/template/src/pages/dashboard/dashboard.page.tsx +82 -50
  93. package/dist/frontend-core/template/src/pages/login/login.page.tsx +1 -1
  94. package/dist/frontend-core/template/src/routes/_auth-routes.tsx +3 -2
  95. package/dist/frontend-core/template/src/styles/theme-default.css +7 -3
  96. package/dist/frontend-core/template/vite.config.ts +5 -0
  97. package/dist/frontend-core/types/component.d.ts +1 -1
  98. package/dist/frontend-core/types/contextprovider.d.ts +1 -1
  99. package/dist/frontend-core/types/hook.d.ts +1 -1
  100. package/dist/frontend-trpc-client/generators/model-hook.generator.js +104 -39
  101. package/dist/frontend-trpc-client/generators/model-hook.generator.js.map +1 -1
  102. package/dist/frontend-trpc-client/trpc-client.generator.js +28 -14
  103. package/dist/frontend-trpc-client/trpc-client.generator.js.map +1 -1
  104. package/dist/types/types.generator.d.ts +7 -0
  105. package/dist/types/types.generator.js +80 -0
  106. package/dist/types/types.generator.js.map +1 -1
  107. package/package.json +3 -3
@@ -39,12 +39,14 @@ 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)
45
47
  .add(model.table.component)
46
48
  .add(context.admin.components.sidebar)
47
- .add(context.admin.components.adminDetailSidebar)
49
+ .add(context.admin.components.auditLogSidebar)
48
50
  .add(model.itemsHook)
49
51
  .addImport({
50
52
  from: Generator.toPackageName('react'),
@@ -85,8 +87,8 @@ function generateModelAdminPage({ model, context, }) {
85
87
  items: [Generator.toFunctionName('useCommandPaletteActions')],
86
88
  })
87
89
  .addImport({
88
- from: Generator.toBackendModuleLocation('@app-actions/ai-assistant-store'),
89
- items: [Generator.toFunctionName('useAiAssistantActions'), Generator.toFunctionName('useAiAssistantState')],
90
+ from: Generator.toBackendModuleLocation('@app-actions/ai-sidebar-content'),
91
+ items: [Generator.toFunctionName('AiSidebarContent')],
90
92
  })
91
93
  .addImport({
92
94
  from: Generator.toBackendModuleLocation('@lib/command-palette-filter.utils'),
@@ -100,101 +102,45 @@ function generateModelAdminPage({ model, context, }) {
100
102
  ],
101
103
  });
102
104
  const showComments = model.name !== 'Comment' && !!context.admin.components.commentSidebar;
103
- const relatedModels = [];
104
- for (const relatedModelName of model.relatedModelNames) {
105
- const relatedModel = context.models.get(relatedModelName);
106
- imports.add(relatedModel.itemsHook);
107
- relatedModels.push({
108
- model: relatedModel,
109
- mapName: relatedModel.internalPluralName,
110
- mapDefinition: `const {${relatedModel.itemsHook.map.name}: ${relatedModel.internalPluralName}} = ${relatedModel.itemsHook.name}()`,
111
- });
112
- }
113
- let createModalHook = '';
114
- let createModalIntentHook = '';
115
- let createModal = '';
116
- if (!model.isReadonly) {
117
- imports
118
- //
119
- .addType(model.types.id)
120
- .addImport({ from: Generator.toPackageName('react'), items: [Generator.toFunctionName('useRef')] })
121
- .addImport({ from: Generator.toPackageName('sonner'), items: [Generator.toFunctionName('toast')] })
122
- .addImport({ from: model.forms.createModal.location, items: [model.forms.createModal.name] });
123
- // In case the model does not provide any explicit label field, we generally use the id field as fallback.
124
- // 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.
125
- const hasLabelField = model.labelField.name !== model.idField.name;
126
- const variableName = hasLabelField ? `created${model.name}` : '_';
127
- const label = hasLabelField ? ` "\${${variableName}.${model.labelField.name}}"` : '';
128
- createModalHook = ` const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)`;
129
- createModalIntentHook = `
130
- useEffect(() => {
131
- if (typeof globalThis === 'undefined') {
132
- return
133
- }
134
-
135
- const storageKey = 'pxl:create-intent-model'
136
- const targetModel = '${model.name}'
137
- const openFromIntent = (intentModel: unknown) => {
138
- if (intentModel === targetModel) {
139
- setIsCreateModalOpen(true)
140
- globalThis.sessionStorage.removeItem(storageKey)
141
- }
142
- }
143
-
144
- openFromIntent(globalThis.sessionStorage.getItem(storageKey))
145
-
146
- const onIntent = (event: Event) => {
147
- const detail = (event as CustomEvent<{ model?: string }>).detail
148
- openFromIntent(detail?.model)
149
- }
150
-
151
- globalThis.addEventListener('pxl:create-intent', onIntent)
152
- return () => {
153
- globalThis.removeEventListener('pxl:create-intent', onIntent)
154
- }
155
- }, [])`;
156
- createModal = `
157
- <${model.forms.createModal.name}
158
- open={isCreateModalOpen}
159
- onClose={() => setIsCreateModalOpen(false)}
160
- onSuccess={(${variableName}) => {
161
- toast.success(\`${model.userFriendlyName}${label} created successfully!\`)
162
- setIsCreateModalOpen(false)
163
- }}
164
- onError={(${variableName}, error) => {
165
- toast.error(\`Error creating ${model.userFriendlyName}${label}: \${String(error)}\`)
166
- }}
167
- />`;
168
- }
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);
169
121
  return `
170
- import { SidebarInset, SidebarProvider, useDebouncedCallback } from '@postxl/ui-components'
171
- import useLocalStorageState from 'use-local-storage-state'
122
+ import { SidebarTab, useDebouncedCallback } from '@postxl/ui-components'
123
+ import { APP_CONFIG } from '@lib/config'
172
124
  import { useAuth } from '@context-providers/auth-context-provider'
125
+ import { hasAnyRole } from '@lib/authorization'
173
126
  ${imports.generate()}
174
127
 
175
- const SIDEBAR_WIDTH_KEY = 'audit-log-sidebar-width'
176
-
177
128
  export default function ${model.admin.page.pageComponent.name}() {
178
129
  const navigate = useNavigate()
179
130
  const { viewerData } = useAuth()
180
131
  const currentUserId = viewerData?.user?.id
181
- const isAdmin = viewerData?.userRoles?.includes('admin') ?? false
182
-
132
+ const userRoles = viewerData?.userRoles ?? []
133
+ const canAccessAdminPage = ${canAccessAdminExpression}
134
+ const canWriteModel = ${canWriteModelExpression}
135
+ ${commentCountState}
136
+
183
137
  const { selectedKey, focusedCell } = useSearch({
184
138
  from: '/_auth-routes${model.admin.page.route}',
185
139
  select: (search) => ({ selectedKey: search.selectedKey, focusedCell: search.focusedCell }),
186
140
  })
187
141
 
188
- ${createModalHook}
189
- ${createModalIntentHook}
190
- const sidepanelOpen = useAiAssistantState((state) => state.open)
191
- const { setOpen: setSidepanelOpen } = useAiAssistantActions()
192
-
193
- // audit log sidebar width
194
- const [sidebarWidth, setSidebarWidth] = useLocalStorageState(SIDEBAR_WIDTH_KEY, {
195
- defaultValue: 350,
196
- })
197
-
142
+ ${createModalContent.hook}
143
+ ${createModalContent.intentHook}
198
144
  // Track selected row IDs from checkbox selection
199
145
  const [selectedRowIds, setSelectedRowIds] = useState<string[]>([])
200
146
 
@@ -212,7 +158,7 @@ export default function ${model.admin.page.pageComponent.name}() {
212
158
  hasNextPage,
213
159
  fetchNextPage,
214
160
  total,
215
- ${model.isReadonly ? '' : `${model.itemsHook.updateField.name}: updateFieldMutation, ${model.itemsHook.updateFieldMany.name}: updateFieldManyMutation, ${model.itemsHook.deleteMany.name}: deleteManyMutation,`}
161
+ ${modelHookMutationFields}
216
162
  } = ${model.itemsHook.name}({ filters, sort: sortState })
217
163
 
218
164
  // Queries for related models' maps used by the table (passed as separate props)
@@ -230,18 +176,7 @@ export default function ${model.admin.page.pageComponent.name}() {
230
176
  return focusedCell
231
177
  }, [focusedCell])
232
178
 
233
- ${showComments
234
- ? `// Comment sidebar: compute filtered entity IDs and label map from table data
235
- const commentFilteredIds = useMemo(() =>
236
- filteredDataList?.map(item => String(item.id)) ?? [],
237
- [filteredDataList]
238
- )
239
- const commentLabelMap = useMemo(() =>
240
- Object.fromEntries((filteredDataList ?? []).map(item => [String(item.id), String(item.${model.labelField.name})])),
241
- [filteredDataList]
242
- )
243
- `
244
- : ''}
179
+ ${commentSidebarLogic}
245
180
  // Callback when row selection changes (checkbox)
246
181
  const handleRowSelectionChange = useCallback((rowIds: string[]) => {
247
182
  setSelectedRowIds(rowIds)
@@ -284,7 +219,7 @@ export default function ${model.admin.page.pageComponent.name}() {
284
219
  // Callback when cell focus changes in the table — also sets selectedKey from the focused row
285
220
  const handleCellChange = useCallback((args: { rowIndex: number; columnId: string }) => {
286
221
  const row = filteredDataList?.[args.rowIndex]
287
- 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})
288
223
  void navigate({ to: '${model.admin.page.route}', search: { selectedKey: key, focusedCell: args.columnId || undefined } })
289
224
  // Clear checkbox selection when focusing a cell
290
225
  setSelectedRowIds([])
@@ -292,6 +227,18 @@ export default function ${model.admin.page.pageComponent.name}() {
292
227
 
293
228
  useEffect(() => {
294
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
+ }
295
242
 
296
243
  const filterActions = Object.entries(${model.types.filter.config.name}).map(([field, fieldConfig]) =>
297
244
  input({
@@ -299,14 +246,7 @@ export default function ${model.admin.page.pageComponent.name}() {
299
246
  visibility: 'visible',
300
247
  group: 'Filter records by...',
301
248
  label: toFilterActionLabel(field),
302
- inputSchema:
303
- fieldConfig.valueType === 'number'
304
- ? numberFilterInputSchema
305
- : fieldConfig.valueType === 'date'
306
- ? dateFilterInputSchema
307
- : fieldConfig.valueType === 'boolean'
308
- ? booleanFilterInputSchema
309
- : stringFilterInputSchema,
249
+ inputSchema: getFilterInputSchema(fieldConfig.valueType),
310
250
  run: async (query, inputValue) => {
311
251
  if (fieldConfig.valueType === 'number') {
312
252
  const parsed = numberFilterInputSchema.safeParse(inputValue)
@@ -400,9 +340,205 @@ export default function ${model.admin.page.pageComponent.name}() {
400
340
  setSearchInput,
401
341
  ])
402
342
 
403
- ${model.isReadonly
404
- ? ''
405
- : `// Batch cell updates: collect updates within the same microtask and send as one updateFieldMany
343
+ ${commentSelectedEntityId}
344
+
345
+ ${cellUpdateHandler}
346
+
347
+ ${removeRowsHandler}
348
+
349
+ if (!canAccessAdminPage) {
350
+ return null
351
+ }
352
+
353
+ return (
354
+ <>
355
+ <SidebarTab
356
+ side="left"
357
+ id="admin-navigation"
358
+ icon={ListTree}
359
+ label="Navigation"
360
+ >
361
+ <AdminSidebarNavContent />
362
+ </SidebarTab>
363
+ ${commentSidebarTab}
364
+ <SidebarTab
365
+ side="right"
366
+ id="audit-log-content"
367
+ icon={ScrollText}
368
+ label="Audit Log"
369
+ order={1}
370
+ >
371
+ <AuditLogSidebar
372
+ model="${model.name}"
373
+ entityId={selectedEntityId}
374
+ entityIds={selectedRowIds}
375
+ field={selectedField}
376
+ labelField="${model.labelField.name}"
377
+ onClearField={handleClearField}
378
+ onClearEntity={handleClearEntity}
379
+ />
380
+ </SidebarTab>
381
+
382
+ {APP_CONFIG.enableAI && (
383
+ <SidebarTab
384
+ side="right"
385
+ id="ai-assistant-content"
386
+ icon={Sparkles}
387
+ label="AI Assistant"
388
+ order={2}
389
+ >
390
+ <AiSidebarContent />
391
+ </SidebarTab>
392
+ )}
393
+ <div className="p-4 overflow-hidden gap-4">
394
+ <h3 className="font-bold text-[1.75rem]">${model.userFriendlyName}</h3>
395
+ <${model.table.component.name}
396
+ data={filteredDataList ?? []}
397
+ filters={filters}
398
+ onFilterChange={(filterKey, val) => {
399
+ const config = ${model.types.filter.config.name}[filterKey]
400
+ if (config.valueType === 'boolean') {
401
+ handleFilterChange(
402
+ config.filterKey,
403
+ (val as string[]).map((v) => v === 'true'),
404
+ )
405
+ } else {
406
+ handleFilterChange(config.filterKey, val)
407
+ }
408
+ }}
409
+ searchInput={searchInput}
410
+ onSearchInputChange={handleSearchInputChange}
411
+ sort={sortState}
412
+ onSortChange={setSortState}
413
+ ${cellUpdateProp}
414
+ ${addRowProp}
415
+ ${removeRowsProp}
416
+ onCellChange={handleCellChange}
417
+ onRowSelectionChange={handleRowSelectionChange}
418
+ selectedKey={selectedKey}
419
+ keyField="${model.keyField.name}"
420
+ hasNextPage={hasNextPage}
421
+ fetchNextPage={fetchNextPage}
422
+ total={total}
423
+ model="${model._conjugated.camelCase}"
424
+ currentUserId={currentUserId}
425
+ isAdmin={canWriteModel}
426
+ onFiltersReplace={setFilters}
427
+ onSortReplace={setSortState}
428
+ ${relatedTableProps}
429
+ />
430
+ </div>
431
+
432
+ ${createModalContent.modal}
433
+ </>
434
+ )
435
+ }
436
+ `;
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
406
542
  const pendingUpdatesRef = useRef<Map<string, { field: string; value: unknown; ids: ${model.types.id.name}[] }> | null>(null)
407
543
 
408
544
  const handleCellUpdate = useCallback((args: { rowIndex: number; columnId: string; value: unknown }) => {
@@ -418,11 +554,7 @@ export default function ${model.admin.page.pageComponent.name}() {
418
554
  if (!pending) return
419
555
 
420
556
  for (const entry of pending.values()) {
421
- if (entry.ids.length === 1) {
422
- void updateFieldMutation({ id: entry.ids[0], field: entry.field, value: entry.value } as Parameters<typeof updateFieldMutation>[0])
423
- } else {
424
- void updateFieldManyMutation({ ids: entry.ids, field: entry.field, value: entry.value } as Parameters<typeof updateFieldManyMutation>[0])
425
- }
557
+ ${flushMutations}
426
558
  }
427
559
  })
428
560
  }
@@ -435,81 +567,161 @@ export default function ${model.admin.page.pageComponent.name}() {
435
567
  } else {
436
568
  pendingUpdatesRef.current.set(key, { field: args.columnId, value: args.value, ids: [row.id] })
437
569
  }
438
- }, [filteredDataList, updateFieldMutation, updateFieldManyMutation])
439
-
440
- function handleRemoveRows(rows: ${model.types.id.name}[]) {
570
+ }, ${dependencies})`;
571
+ }
572
+ function buildRemoveRowsHandler(model) {
573
+ if (!model.itemsHook.deleteMany) {
574
+ return '';
575
+ }
576
+ return `function handleRemoveRows(rows: ${model.types.id.name}[]) {
441
577
  void deleteManyMutation(rows)
442
- }
443
- `}
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
+ }
444
643
 
445
- return (
446
- ${model.isReadonly ? '' : '<>'}
447
- <SidebarProvider open={sidepanelOpen} onOpenChange={setSidepanelOpen} width={sidebarWidth} onWidthChange={setSidebarWidth}>
448
- <AdminSidebar collapsible="none" className="w-64" />
449
-
450
- <SidebarInset className="p-4 overflow-hidden gap-4">
451
- <h3 className="font-bold text-[1.75rem]">${model.userFriendlyName}</h3>
452
- <${model.table.component.name}
453
- data={filteredDataList ?? []}
454
- filters={filters}
455
- onFilterChange={(filterKey, val) => {
456
- const config = ${model.types.filter.config.name}[filterKey]
457
- if (config.valueType === 'boolean') {
458
- handleFilterChange(
459
- config.filterKey,
460
- (val as string[]).map((v) => v === 'true'),
461
- )
462
- } else {
463
- handleFilterChange(config.filterKey, val)
464
- }
465
- }}
466
- searchInput={searchInput}
467
- onSearchInputChange={handleSearchInputChange}
468
- sort={sortState}
469
- onSortChange={setSortState}
470
- ${model.isReadonly
471
- ? ''
472
- : `onCellUpdate={handleCellUpdate}
473
- handleAddRow={() => setIsCreateModalOpen(true)}
474
- handleRemoveRows={handleRemoveRows}`}
475
- onCellChange={handleCellChange}
476
- onRowSelectionChange={handleRowSelectionChange}
477
- selectedKey={selectedKey}
478
- keyField="${model.keyField.name}"
479
- hasNextPage={hasNextPage}
480
- fetchNextPage={fetchNextPage}
481
- total={total}
482
- model="${model._conjugated.camelCase}"
483
- currentUserId={currentUserId}
484
- isAdmin={isAdmin}
485
- onFiltersReplace={setFilters}
486
- onSortReplace={setSortState}
487
- ${relatedModels.map((rm) => `${rm.model.table.propName}={${rm.mapName}}`).join('\n ')}
488
- />
489
- </SidebarInset>
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
+ }
490
652
 
491
- <AdminDetailSidebar
492
- side="right"
493
- collapsible="offcanvas"
494
- model="${model.name}"
495
- entityId={selectedEntityId}
496
- entityIds={selectedRowIds}
497
- field={selectedField}
498
- labelField="${model.labelField.name}"
499
- onClearField={handleClearField}
500
- onClearEntity={handleClearEntity}
501
- ${showComments
502
- ? `filteredIds={commentFilteredIds}
503
- labelMap={commentLabelMap}
504
- selectedEntityId={selectedEntityId ? String(selectedEntityId) : selectedRowIds.length === 1 ? selectedRowIds[0] : undefined}`
505
- : ''}
506
- />
507
- </SidebarProvider>
508
-
509
- ${createModal}
510
- ${model.isReadonly ? '' : '</>'}
511
- )
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: '' };
512
680
  }
513
- `;
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: [] };
514
726
  }
515
727
  //# sourceMappingURL=model-admin-page.generator.js.map