@pilotiq/pilotiq 0.6.1 → 0.7.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.
- package/.turbo/turbo-build.log +6 -2
- package/CHANGELOG.md +614 -0
- package/CLAUDE.md +6 -5
- package/dist/Column.d.ts +35 -0
- package/dist/Column.d.ts.map +1 -1
- package/dist/Column.js +41 -0
- package/dist/Column.js.map +1 -1
- package/dist/Page.d.ts +13 -4
- package/dist/Page.d.ts.map +1 -1
- package/dist/Page.js +9 -2
- package/dist/Page.js.map +1 -1
- package/dist/Pilotiq.d.ts +84 -0
- package/dist/Pilotiq.d.ts.map +1 -1
- package/dist/Pilotiq.js +66 -0
- package/dist/Pilotiq.js.map +1 -1
- package/dist/Resource.d.ts +26 -0
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +9 -0
- package/dist/Resource.js.map +1 -1
- package/dist/actions/exportFactory.js +1 -1
- package/dist/actions/exportFactory.js.map +1 -1
- package/dist/columns/SelectColumn.d.ts +32 -5
- package/dist/columns/SelectColumn.d.ts.map +1 -1
- package/dist/columns/SelectColumn.js +37 -7
- package/dist/columns/SelectColumn.js.map +1 -1
- package/dist/defaultPages.d.ts.map +1 -1
- package/dist/defaultPages.js +3 -0
- package/dist/defaultPages.js.map +1 -1
- package/dist/elements/Form.d.ts +17 -0
- package/dist/elements/Form.d.ts.map +1 -1
- package/dist/elements/Form.js +17 -0
- package/dist/elements/Form.js.map +1 -1
- package/dist/elements/Table.d.ts +26 -0
- package/dist/elements/Table.d.ts.map +1 -1
- package/dist/elements/Table.js +15 -1
- package/dist/elements/Table.js.map +1 -1
- package/dist/elements/TableGroup.d.ts +84 -0
- package/dist/elements/TableGroup.d.ts.map +1 -1
- package/dist/elements/TableGroup.js +103 -0
- package/dist/elements/TableGroup.js.map +1 -1
- package/dist/elements/dispatchForm.d.ts.map +1 -1
- package/dist/elements/dispatchForm.js +36 -6
- package/dist/elements/dispatchForm.js.map +1 -1
- package/dist/elements/dispatchTable.d.ts +12 -0
- package/dist/elements/dispatchTable.d.ts.map +1 -1
- package/dist/elements/dispatchTable.js +104 -29
- package/dist/elements/dispatchTable.js.map +1 -1
- package/dist/fields/Field.d.ts +7 -2
- package/dist/fields/Field.d.ts.map +1 -1
- package/dist/fields/Field.js +8 -3
- package/dist/fields/Field.js.map +1 -1
- package/dist/fields/RepeaterField.d.ts +65 -0
- package/dist/fields/RepeaterField.d.ts.map +1 -1
- package/dist/fields/RepeaterField.js +48 -0
- package/dist/fields/RepeaterField.js.map +1 -1
- package/dist/orm/modelDefaults.d.ts.map +1 -1
- package/dist/orm/modelDefaults.js +19 -0
- package/dist/orm/modelDefaults.js.map +1 -1
- package/dist/pageData.d.ts +20 -0
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +242 -34
- package/dist/pageData.js.map +1 -1
- package/dist/react/AppShell.d.ts +17 -1
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js +34 -3
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/PendingSuggestionApplierRegistry.d.ts +34 -0
- package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -0
- package/dist/react/PendingSuggestionApplierRegistry.js +51 -0
- package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -0
- package/dist/react/PendingSuggestionOverlayRegistry.d.ts +46 -0
- package/dist/react/PendingSuggestionOverlayRegistry.d.ts.map +1 -0
- package/dist/react/PendingSuggestionOverlayRegistry.js +16 -0
- package/dist/react/PendingSuggestionOverlayRegistry.js.map +1 -0
- package/dist/react/PendingSuggestionsContext.d.ts +153 -0
- package/dist/react/PendingSuggestionsContext.d.ts.map +1 -0
- package/dist/react/PendingSuggestionsContext.js +46 -0
- package/dist/react/PendingSuggestionsContext.js.map +1 -0
- package/dist/react/SchemaRenderer.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.js +312 -39
- package/dist/react/SchemaRenderer.js.map +1 -1
- package/dist/react/cells/EditableCell.d.ts +8 -0
- package/dist/react/cells/EditableCell.d.ts.map +1 -1
- package/dist/react/cells/EditableCell.js +6 -2
- package/dist/react/cells/EditableCell.js.map +1 -1
- package/dist/react/fields/CheckboxListInput.d.ts.map +1 -1
- package/dist/react/fields/CheckboxListInput.js +29 -2
- package/dist/react/fields/CheckboxListInput.js.map +1 -1
- package/dist/react/fields/ColorInput.d.ts.map +1 -1
- package/dist/react/fields/ColorInput.js +28 -2
- package/dist/react/fields/ColorInput.js.map +1 -1
- package/dist/react/fields/DateTimeInput.d.ts.map +1 -1
- package/dist/react/fields/DateTimeInput.js +28 -2
- package/dist/react/fields/DateTimeInput.js.map +1 -1
- package/dist/react/fields/FieldShell.d.ts.map +1 -1
- package/dist/react/fields/FieldShell.js +161 -3
- package/dist/react/fields/FieldShell.js.map +1 -1
- package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
- package/dist/react/fields/FileUploadInput.js +27 -2
- package/dist/react/fields/FileUploadInput.js.map +1 -1
- package/dist/react/fields/KeyValueInput.d.ts.map +1 -1
- package/dist/react/fields/KeyValueInput.js +33 -2
- package/dist/react/fields/KeyValueInput.js.map +1 -1
- package/dist/react/fields/RadioInput.d.ts.map +1 -1
- package/dist/react/fields/RadioInput.js +28 -2
- package/dist/react/fields/RadioInput.js.map +1 -1
- package/dist/react/fields/SelectFieldInput.d.ts.map +1 -1
- package/dist/react/fields/SelectFieldInput.js +31 -2
- package/dist/react/fields/SelectFieldInput.js.map +1 -1
- package/dist/react/fields/SliderInput.d.ts.map +1 -1
- package/dist/react/fields/SliderInput.js +26 -2
- package/dist/react/fields/SliderInput.js.map +1 -1
- package/dist/react/fields/TagsInput.d.ts.map +1 -1
- package/dist/react/fields/TagsInput.js +26 -2
- package/dist/react/fields/TagsInput.js.map +1 -1
- package/dist/react/fields/ToggleFieldInput.d.ts.map +1 -1
- package/dist/react/fields/ToggleFieldInput.js +29 -2
- package/dist/react/fields/ToggleFieldInput.js.map +1 -1
- package/dist/react/index.d.ts +3 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +3 -0
- package/dist/react/index.js.map +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +55 -2
- package/dist/routes.js.map +1 -1
- package/dist/schema/Html.d.ts +2 -2
- package/dist/schema/Html.d.ts.map +1 -1
- package/dist/schema/Html.js +2 -2
- package/dist/schema/Html.js.map +1 -1
- package/dist/schema/Markdown.d.ts +2 -2
- package/dist/schema/Markdown.d.ts.map +1 -1
- package/dist/schema/Markdown.js +2 -2
- package/dist/schema/Markdown.js.map +1 -1
- package/dist/schema/Section.d.ts +16 -0
- package/dist/schema/Section.d.ts.map +1 -1
- package/dist/schema/Section.js +16 -0
- package/dist/schema/Section.js.map +1 -1
- package/dist/schema/Wizard.d.ts +45 -0
- package/dist/schema/Wizard.d.ts.map +1 -1
- package/dist/schema/Wizard.js +50 -0
- package/dist/schema/Wizard.js.map +1 -1
- package/dist/schema/resolveSchema.d.ts +8 -0
- package/dist/schema/resolveSchema.d.ts.map +1 -1
- package/dist/schema/resolveSchema.js +70 -1
- package/dist/schema/resolveSchema.js.map +1 -1
- package/dist/schema/sanitize.d.ts +3 -3
- package/dist/schema/sanitize.d.ts.map +1 -1
- package/dist/schema/sanitize.js +10 -3
- package/dist/schema/sanitize.js.map +1 -1
- package/dist/sessionFilters.d.ts.map +1 -1
- package/dist/sessionFilters.js +12 -1
- package/dist/sessionFilters.js.map +1 -1
- package/dist/styles/file-upload.css +13 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +9 -2
- package/dist/vite.js.map +1 -1
- package/package.json +6 -4
- package/src/Column.test.ts +36 -0
- package/src/Column.ts +54 -0
- package/src/Page.ts +13 -4
- package/src/Pilotiq.ts +109 -0
- package/src/Resource.ts +29 -0
- package/src/actions/exportFactory.ts +1 -1
- package/src/columns/SelectColumn.ts +46 -8
- package/src/columns/editableColumns.test.ts +45 -0
- package/src/defaultPages.ts +3 -0
- package/src/elements/Form.ts +19 -0
- package/src/elements/Table.ts +35 -1
- package/src/elements/TableGroup.test.ts +111 -0
- package/src/elements/TableGroup.ts +135 -0
- package/src/elements/dispatchForm.ts +34 -7
- package/src/elements/dispatchTable.test.ts +267 -0
- package/src/elements/dispatchTable.ts +112 -33
- package/src/fields/Field.test.ts +15 -0
- package/src/fields/Field.ts +8 -3
- package/src/fields/RepeaterField.ts +104 -0
- package/src/fields/RepeaterRelationship.test.ts +173 -0
- package/src/nestedRelationManagerData.test.ts +21 -0
- package/src/orm/modelDefaults.ts +21 -0
- package/src/pageData.ts +267 -47
- package/src/react/AppShell.tsx +55 -4
- package/src/react/PendingSuggestionApplierRegistry.ts +80 -0
- package/src/react/PendingSuggestionOverlayRegistry.ts +54 -0
- package/src/react/PendingSuggestionsContext.tsx +172 -0
- package/src/react/SchemaRenderer.tsx +504 -95
- package/src/react/cells/EditableCell.tsx +11 -2
- package/src/react/fields/CheckboxListInput.tsx +23 -2
- package/src/react/fields/ColorInput.tsx +22 -2
- package/src/react/fields/DateTimeInput.tsx +22 -2
- package/src/react/fields/FieldShell.tsx +167 -3
- package/src/react/fields/FileUploadInput.tsx +21 -2
- package/src/react/fields/KeyValueInput.tsx +32 -2
- package/src/react/fields/RadioInput.tsx +23 -2
- package/src/react/fields/SelectFieldInput.tsx +25 -2
- package/src/react/fields/SliderInput.tsx +20 -2
- package/src/react/fields/TagsInput.tsx +20 -2
- package/src/react/fields/ToggleFieldInput.tsx +23 -2
- package/src/react/index.ts +18 -0
- package/src/relationManagerData.test.ts +451 -2
- package/src/routes.ts +58 -2
- package/src/schema/Html.ts +2 -2
- package/src/schema/Markdown.ts +2 -2
- package/src/schema/Section.ts +17 -0
- package/src/schema/Wizard.ts +67 -0
- package/src/schema/containers.test.ts +90 -0
- package/src/schema/resolveSchema.test.ts +50 -0
- package/src/schema/resolveSchema.ts +79 -1
- package/src/schema/sanitize.ts +13 -4
- package/src/sessionFilters.test.ts +23 -0
- package/src/sessionFilters.ts +11 -1
- package/src/styles/file-upload.css +13 -0
- package/src/vite.ts +9 -2
package/src/pageData.ts
CHANGED
|
@@ -244,6 +244,12 @@ export async function panelInfo(
|
|
|
244
244
|
buildRightSidebarMeta(cfg, user),
|
|
245
245
|
])
|
|
246
246
|
const databaseNotifications = buildDatabaseNotificationsMeta(cfg, user)
|
|
247
|
+
// AI suggestion mode — sparse: omit when 'auto' (the default) so the
|
|
248
|
+
// wire shape stays minimal for panels that don't opt into review mode.
|
|
249
|
+
// Plugin clients (e.g. @pilotiq-pro/ai's `AiClientToolBindings`) read
|
|
250
|
+
// this to decide whether to apply writes immediately or stage them as
|
|
251
|
+
// PendingSuggestions for user approval.
|
|
252
|
+
const aiSuggestionsMode = pilotiq.getAiSuggestionsMode()
|
|
247
253
|
return {
|
|
248
254
|
name: cfg.name,
|
|
249
255
|
branding: cfg.branding,
|
|
@@ -254,6 +260,7 @@ export async function panelInfo(
|
|
|
254
260
|
...(databaseNotifications ? { databaseNotifications } : {}),
|
|
255
261
|
...(rightSidebar ? { rightSidebar } : {}),
|
|
256
262
|
...(Object.keys(renderHooks).length > 0 ? { renderHooks } : {}),
|
|
263
|
+
...(aiSuggestionsMode !== 'auto' ? { aiSuggestionsMode } : {}),
|
|
257
264
|
}
|
|
258
265
|
}
|
|
259
266
|
|
|
@@ -1872,7 +1879,7 @@ export async function resourceEditData(
|
|
|
1872
1879
|
// navigation strip so users can drill into each manager's table
|
|
1873
1880
|
// without leaving the parent record context. The "Edit" tab is
|
|
1874
1881
|
// active here.
|
|
1875
|
-
const relationTabsEl = buildRelationTabs(R, recordId, cfg.path, '__edit')
|
|
1882
|
+
const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, '__edit', user, record)
|
|
1876
1883
|
if (relationTabsEl) elements.unshift(relationTabsEl)
|
|
1877
1884
|
|
|
1878
1885
|
const recordTitle = record !== undefined && record !== null
|
|
@@ -2237,7 +2244,7 @@ async function buildRelationListData(
|
|
|
2237
2244
|
tagActionDispatch(elements, listUrl)
|
|
2238
2245
|
await loadTableRecords(elements, scope.query ?? {}, listUrl, user)
|
|
2239
2246
|
|
|
2240
|
-
const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship)
|
|
2247
|
+
const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
|
|
2241
2248
|
if (tabs) elements.unshift(tabs)
|
|
2242
2249
|
|
|
2243
2250
|
const breadcrumbs = relationListBreadcrumbs(
|
|
@@ -2313,7 +2320,7 @@ async function buildRelationCreateData(
|
|
|
2313
2320
|
if (scope.prefill.errors) form.withErrors(scope.prefill.errors)
|
|
2314
2321
|
}
|
|
2315
2322
|
|
|
2316
|
-
const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship)
|
|
2323
|
+
const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
|
|
2317
2324
|
if (tabs) elements.unshift(tabs)
|
|
2318
2325
|
|
|
2319
2326
|
const breadcrumbs = relationCreateBreadcrumbs(
|
|
@@ -2406,15 +2413,16 @@ async function buildRelationViewData(
|
|
|
2406
2413
|
// view straight into a grandchild list / create / view / edit page.
|
|
2407
2414
|
// Active key `'__view'` because the user is currently viewing the
|
|
2408
2415
|
// leaf parent record itself, not any nested manager.
|
|
2409
|
-
const nestedTabs = buildNestedRelationTabs(
|
|
2416
|
+
const nestedTabs = await buildNestedRelationTabs(
|
|
2410
2417
|
R, M, base,
|
|
2411
2418
|
{ recordId: scope.recordId, relationship: scope.relationship },
|
|
2412
2419
|
scope.childId,
|
|
2413
2420
|
'__view',
|
|
2421
|
+
user, child,
|
|
2414
2422
|
)
|
|
2415
2423
|
if (nestedTabs) elements.unshift(nestedTabs)
|
|
2416
2424
|
|
|
2417
|
-
const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship)
|
|
2425
|
+
const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
|
|
2418
2426
|
if (tabs) elements.unshift(tabs)
|
|
2419
2427
|
|
|
2420
2428
|
const breadcrumbs = relationViewBreadcrumbs(
|
|
@@ -2524,7 +2532,7 @@ async function buildRelationEditData(
|
|
|
2524
2532
|
form.withValues(values)
|
|
2525
2533
|
}
|
|
2526
2534
|
|
|
2527
|
-
const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship)
|
|
2535
|
+
const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
|
|
2528
2536
|
if (tabs) elements.unshift(tabs)
|
|
2529
2537
|
|
|
2530
2538
|
const breadcrumbs = relationEditBreadcrumbs(
|
|
@@ -2844,7 +2852,7 @@ async function buildNestedRelationListData(
|
|
|
2844
2852
|
tagActionDispatch(elements, listUrl)
|
|
2845
2853
|
await loadTableRecords(elements, scope.query ?? {}, listUrl, user)
|
|
2846
2854
|
|
|
2847
|
-
const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship)
|
|
2855
|
+
const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
|
|
2848
2856
|
if (tabs) elements.unshift(tabs)
|
|
2849
2857
|
|
|
2850
2858
|
const breadcrumbs = nestedRelationListBreadcrumbs(
|
|
@@ -2898,7 +2906,7 @@ async function buildNestedRelationCreateData(
|
|
|
2898
2906
|
if (scope.prefill.errors) form.withErrors(scope.prefill.errors)
|
|
2899
2907
|
}
|
|
2900
2908
|
|
|
2901
|
-
const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship)
|
|
2909
|
+
const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
|
|
2902
2910
|
if (tabs) elements.unshift(tabs)
|
|
2903
2911
|
|
|
2904
2912
|
const breadcrumbs = nestedRelationCreateBreadcrumbs(
|
|
@@ -2963,7 +2971,7 @@ async function buildNestedRelationViewData(
|
|
|
2963
2971
|
|
|
2964
2972
|
const elements: Element[] = M2.detail(child2, child1)
|
|
2965
2973
|
|
|
2966
|
-
const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship)
|
|
2974
|
+
const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
|
|
2967
2975
|
if (tabs) elements.unshift(tabs)
|
|
2968
2976
|
|
|
2969
2977
|
const breadcrumbs = nestedRelationViewBreadcrumbs(
|
|
@@ -3045,7 +3053,7 @@ async function buildNestedRelationEditData(
|
|
|
3045
3053
|
form.withValues(values)
|
|
3046
3054
|
}
|
|
3047
3055
|
|
|
3048
|
-
const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship)
|
|
3056
|
+
const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
|
|
3049
3057
|
if (tabs) elements.unshift(tabs)
|
|
3050
3058
|
|
|
3051
3059
|
const breadcrumbs = nestedRelationEditBreadcrumbs(
|
|
@@ -3092,21 +3100,38 @@ async function buildNestedRelationEditData(
|
|
|
3092
3100
|
* absent that, callers skip the prepend so single-manager surfaces stay
|
|
3093
3101
|
* clean. `activeKey` accepts the literal `'__view'` for the leaf
|
|
3094
3102
|
* parent's view tab, or any sibling manager's relationship key.
|
|
3103
|
+
*
|
|
3104
|
+
* Per-tab `canX` gating (2026-05-11) — sibling nested-manager tabs run
|
|
3105
|
+
* `N.canViewAny(user, child1Record)` (with fall-through to the related
|
|
3106
|
+
* Resource via `safeManagerPolicy`) so the strip hides tabs the user
|
|
3107
|
+
* couldn't reach anyway. The back-link `__view` stays unconditional
|
|
3108
|
+
* since the user is already on a page scoped under `M.canViewAny` —
|
|
3109
|
+
* they reached this strip, they can navigate back to it.
|
|
3095
3110
|
*/
|
|
3096
|
-
function buildNestedRelationTabs(
|
|
3097
|
-
R:
|
|
3098
|
-
M:
|
|
3099
|
-
basePath:
|
|
3100
|
-
step0:
|
|
3101
|
-
child1Id:
|
|
3102
|
-
activeKey:
|
|
3103
|
-
|
|
3111
|
+
async function buildNestedRelationTabs(
|
|
3112
|
+
R: ResourceClass,
|
|
3113
|
+
M: typeof RelationManager,
|
|
3114
|
+
basePath: string,
|
|
3115
|
+
step0: RelationChainStep,
|
|
3116
|
+
child1Id: string,
|
|
3117
|
+
activeKey: string,
|
|
3118
|
+
user: unknown,
|
|
3119
|
+
child1Record: unknown,
|
|
3120
|
+
): Promise<RelationTabs | undefined> {
|
|
3104
3121
|
const siblings = M.relations()
|
|
3105
3122
|
if (siblings.length === 0) return undefined
|
|
3106
3123
|
|
|
3107
3124
|
const resourceBase = resourceBasePath(basePath, R)
|
|
3108
3125
|
const parentBase = `${resourceBase}/${step0.recordId}/${step0.relationship}`
|
|
3109
3126
|
|
|
3127
|
+
// Sibling gating runs in parallel — each predicate may hit auth /
|
|
3128
|
+
// db, so don't serialize them.
|
|
3129
|
+
const siblingGates = siblings.map(N => {
|
|
3130
|
+
const Related = (N as unknown as { relatedResource?: ResourceClass }).relatedResource
|
|
3131
|
+
return safeManagerPolicyImpl(N, 'canViewAny', Related, user, child1Record)
|
|
3132
|
+
})
|
|
3133
|
+
const siblingVisible = await Promise.all(siblingGates)
|
|
3134
|
+
|
|
3110
3135
|
const tabs: RelationTabMeta[] = []
|
|
3111
3136
|
|
|
3112
3137
|
// Back-link: depth-2 view page for the leaf parent record. Acts as
|
|
@@ -3120,9 +3145,10 @@ function buildNestedRelationTabs(
|
|
|
3120
3145
|
iconOwner: M.name,
|
|
3121
3146
|
}))
|
|
3122
3147
|
|
|
3123
|
-
|
|
3148
|
+
siblings.forEach((N, i) => {
|
|
3149
|
+
if (!siblingVisible[i]) return
|
|
3124
3150
|
let nestedRel = ''
|
|
3125
|
-
try { nestedRel = N.getRelationship() } catch {
|
|
3151
|
+
try { nestedRel = N.getRelationship() } catch { return }
|
|
3126
3152
|
const icon = N.getIcon()
|
|
3127
3153
|
tabs.push(relationTab({
|
|
3128
3154
|
key: nestedRel,
|
|
@@ -3131,7 +3157,12 @@ function buildNestedRelationTabs(
|
|
|
3131
3157
|
active: activeKey === nestedRel,
|
|
3132
3158
|
...(icon !== undefined ? { icon, iconOwner: N.name } : {}),
|
|
3133
3159
|
}))
|
|
3134
|
-
}
|
|
3160
|
+
})
|
|
3161
|
+
|
|
3162
|
+
// After gating, only the back-link may remain — one tab isn't a
|
|
3163
|
+
// useful sub-nav. Drop the strip in that case (consistent with
|
|
3164
|
+
// depth-1's empty-tabs branch).
|
|
3165
|
+
if (tabs.length <= 1) return undefined
|
|
3135
3166
|
|
|
3136
3167
|
return RelationTabs.make(tabs)
|
|
3137
3168
|
}
|
|
@@ -3144,11 +3175,25 @@ function buildNestedRelationTabs(
|
|
|
3144
3175
|
* the manager's relationship key for a manager tab.
|
|
3145
3176
|
*
|
|
3146
3177
|
* Sub-nav follow-up (2026-05-03 cont'd) — emit BOTH `__view` and
|
|
3147
|
-
* `__edit` as sibling tabs (
|
|
3148
|
-
*
|
|
3149
|
-
*
|
|
3150
|
-
*
|
|
3151
|
-
*
|
|
3178
|
+
* `__edit` as sibling tabs (record sub-navigation) instead of one
|
|
3179
|
+
* parent tab whose label depends on mode. Tabs are dropped when the
|
|
3180
|
+
* corresponding page role isn't registered (a Resource overriding
|
|
3181
|
+
* `pages()` to omit `view` or `edit` shouldn't surface a tab that
|
|
3182
|
+
* 404s).
|
|
3183
|
+
*
|
|
3184
|
+
* Per-tab `canX` gating (2026-05-11) — the strip now also evaluates
|
|
3185
|
+
* the matching predicate for each tab and drops the entry when the
|
|
3186
|
+
* user can't reach it. Routes still enforce; this is presentation
|
|
3187
|
+
* polish so the chrome doesn't promise a link that 403s on click.
|
|
3188
|
+
*
|
|
3189
|
+
* - `__view` → `R.canView(user, parentRecord)` (skip gating when
|
|
3190
|
+
* `parentRecord` is undefined — record load failed,
|
|
3191
|
+
* so the route's own load+gate will surface a 404/403
|
|
3192
|
+
* rather than the strip hiding silently).
|
|
3193
|
+
* - `__edit` → `R.canEdit(user, parentRecord)` (same posture).
|
|
3194
|
+
* - manager → `safeManagerPolicy(M, 'canViewAny', Related, user,
|
|
3195
|
+
* parentRecord)` (falls through to Related's
|
|
3196
|
+
* `canViewAny` when the manager hasn't overridden).
|
|
3152
3197
|
*
|
|
3153
3198
|
* Returns `undefined` when the resource has no relation managers — the
|
|
3154
3199
|
* caller can then skip the prepend entirely so resources without
|
|
@@ -3156,23 +3201,65 @@ function buildNestedRelationTabs(
|
|
|
3156
3201
|
* (View+Edit sub-nav alone isn't worth a tab strip; users navigate
|
|
3157
3202
|
* those via headerActions or the back link.)
|
|
3158
3203
|
*/
|
|
3159
|
-
function buildRelationTabs(
|
|
3160
|
-
R:
|
|
3161
|
-
recordId:
|
|
3162
|
-
basePath:
|
|
3163
|
-
activeKey:
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3204
|
+
async function buildRelationTabs(
|
|
3205
|
+
R: ResourceClass,
|
|
3206
|
+
recordId: string,
|
|
3207
|
+
basePath: string,
|
|
3208
|
+
activeKey: string,
|
|
3209
|
+
user: unknown,
|
|
3210
|
+
parentRecord: unknown,
|
|
3211
|
+
): Promise<RelationTabs | undefined> {
|
|
3212
|
+
const managers = R.relations()
|
|
3213
|
+
const recordPageMap = R.getRecordPages()
|
|
3214
|
+
const recordPageSlugs = Object.keys(recordPageMap)
|
|
3215
|
+
// No managers AND no record sub-pages → no strip. View+Edit alone
|
|
3216
|
+
// isn't worth a tab strip; users navigate those via headerActions or
|
|
3217
|
+
// the back link. (When either is non-empty, the strip is worth
|
|
3218
|
+
// mounting even if all the dynamic tabs end up gated away — the
|
|
3219
|
+
// post-gate emptiness check below catches that.)
|
|
3220
|
+
if (managers.length === 0 && recordPageSlugs.length === 0) return undefined
|
|
3167
3221
|
|
|
3168
3222
|
const resourceBase = resourceBasePath(basePath, R)
|
|
3169
3223
|
const pages = R.resolvePages()
|
|
3224
|
+
|
|
3225
|
+
// Evaluate every per-tab predicate in parallel. The arrays line up
|
|
3226
|
+
// 1:1 with `pages.view` / `pages.edit` / `recordPageSlugs` /
|
|
3227
|
+
// `managers` below — we resolve all gates first so the tab-build
|
|
3228
|
+
// loop stays straight-line.
|
|
3229
|
+
// Record-aware predicates short-circuit to `true` when no parent
|
|
3230
|
+
// record was loaded (presentation should never hide more aggressively
|
|
3231
|
+
// than the route can enforce; a missing record means the route will
|
|
3232
|
+
// 404/403 on click and the strip stays consistent with that).
|
|
3233
|
+
const canViewPromise = pages.view && parentRecord !== undefined && parentRecord !== null
|
|
3234
|
+
? safeBool(() => R.canView(user, parentRecord))
|
|
3235
|
+
: Promise.resolve(true)
|
|
3236
|
+
const canEditPromise = pages.edit && parentRecord !== undefined && parentRecord !== null
|
|
3237
|
+
? safeBool(() => R.canEdit(user, parentRecord))
|
|
3238
|
+
: Promise.resolve(true)
|
|
3239
|
+
const recordPageGates = recordPageSlugs.map(subSlug => {
|
|
3240
|
+
// Record sub-page gates run against the parent record verbatim —
|
|
3241
|
+
// missing record still calls the predicate so a sub-page that
|
|
3242
|
+
// gates on global user state (no record needed) still evaluates.
|
|
3243
|
+
// safeBool fails closed for throwing predicates.
|
|
3244
|
+
return safeBool(() => recordPageMap[subSlug]!.canAccess(user, parentRecord))
|
|
3245
|
+
})
|
|
3246
|
+
const managerGates = managers.map(M => {
|
|
3247
|
+
const Related = (M as unknown as { relatedResource?: ResourceClass }).relatedResource
|
|
3248
|
+
return safeManagerPolicyImpl(M, 'canViewAny', Related, user, parentRecord)
|
|
3249
|
+
})
|
|
3250
|
+
const gateResults = await Promise.all([
|
|
3251
|
+
canViewPromise, canEditPromise,
|
|
3252
|
+
...recordPageGates,
|
|
3253
|
+
...managerGates,
|
|
3254
|
+
])
|
|
3255
|
+
const canView = gateResults[0] as boolean
|
|
3256
|
+
const canEdit = gateResults[1] as boolean
|
|
3257
|
+
const recordPageVisible = gateResults.slice(2, 2 + recordPageSlugs.length) as boolean[]
|
|
3258
|
+
const managerVisible = gateResults.slice(2 + recordPageSlugs.length) as boolean[]
|
|
3259
|
+
|
|
3170
3260
|
const tabs: RelationTabMeta[] = []
|
|
3171
3261
|
|
|
3172
|
-
|
|
3173
|
-
// Defaults always include one; users who pruned ViewPage in their
|
|
3174
|
-
// `static pages()` override get no broken link.
|
|
3175
|
-
if (pages.view) {
|
|
3262
|
+
if (pages.view && canView) {
|
|
3176
3263
|
tabs.push(relationTab({
|
|
3177
3264
|
key: '__view',
|
|
3178
3265
|
label: 'View',
|
|
@@ -3183,8 +3270,7 @@ function buildRelationTabs(
|
|
|
3183
3270
|
}))
|
|
3184
3271
|
}
|
|
3185
3272
|
|
|
3186
|
-
|
|
3187
|
-
if (pages.edit) {
|
|
3273
|
+
if (pages.edit && canEdit) {
|
|
3188
3274
|
tabs.push(relationTab({
|
|
3189
3275
|
key: '__edit',
|
|
3190
3276
|
label: 'Edit',
|
|
@@ -3198,9 +3284,29 @@ function buildRelationTabs(
|
|
|
3198
3284
|
}))
|
|
3199
3285
|
}
|
|
3200
3286
|
|
|
3201
|
-
|
|
3287
|
+
// Record sub-page tabs — between Edit and the managers, in declaration
|
|
3288
|
+
// order. Tab label inherits from the sub-page's class (`getLabel()`);
|
|
3289
|
+
// icon picks up the sub-page's static `icon` when set. Slug doubles as
|
|
3290
|
+
// the URL segment AND the `activeKey` discriminator the data builder
|
|
3291
|
+
// passes when rendering the sub-page.
|
|
3292
|
+
recordPageSlugs.forEach((subSlug, i) => {
|
|
3293
|
+
if (!recordPageVisible[i]) return
|
|
3294
|
+
const SubPage = recordPageMap[subSlug]!
|
|
3295
|
+
tabs.push(relationTab({
|
|
3296
|
+
key: subSlug,
|
|
3297
|
+
label: SubPage.getLabel(),
|
|
3298
|
+
url: `${resourceBase}/${recordId}/${subSlug}`,
|
|
3299
|
+
active: activeKey === subSlug,
|
|
3300
|
+
...(SubPage.icon !== undefined
|
|
3301
|
+
? { icon: SubPage.icon, iconOwner: SubPage.name }
|
|
3302
|
+
: {}),
|
|
3303
|
+
}))
|
|
3304
|
+
})
|
|
3305
|
+
|
|
3306
|
+
managers.forEach((M, i) => {
|
|
3307
|
+
if (!managerVisible[i]) return
|
|
3202
3308
|
let rel = ''
|
|
3203
|
-
try { rel = M.getRelationship() } catch {
|
|
3309
|
+
try { rel = M.getRelationship() } catch { return }
|
|
3204
3310
|
const icon = M.getIcon()
|
|
3205
3311
|
tabs.push(relationTab({
|
|
3206
3312
|
key: rel,
|
|
@@ -3209,11 +3315,25 @@ function buildRelationTabs(
|
|
|
3209
3315
|
active: activeKey === rel,
|
|
3210
3316
|
...(icon !== undefined ? { icon, iconOwner: M.name } : {}),
|
|
3211
3317
|
}))
|
|
3212
|
-
}
|
|
3318
|
+
})
|
|
3319
|
+
|
|
3320
|
+
// After gating, the strip may collapse to zero entries. Mirror the
|
|
3321
|
+
// "no managers + no sub-pages" branch above — no strip is friendlier
|
|
3322
|
+
// than a one-tab strip with just the active page.
|
|
3323
|
+
if (tabs.length === 0) return undefined
|
|
3213
3324
|
|
|
3214
3325
|
return RelationTabs.make(tabs)
|
|
3215
3326
|
}
|
|
3216
3327
|
|
|
3328
|
+
/**
|
|
3329
|
+
* Tiny shim over `try { Boolean(await fn()) } catch { false }` so the
|
|
3330
|
+
* relation-tabs builder stays straight-line — mirrors `checkPolicy`
|
|
3331
|
+
* in `routes.ts` but kept local to avoid cross-module imports.
|
|
3332
|
+
*/
|
|
3333
|
+
async function safeBool(fn: () => boolean | Promise<boolean>): Promise<boolean> {
|
|
3334
|
+
try { return Boolean(await fn()) } catch { return false }
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3217
3337
|
/** Pull a human-readable title off a parent record for breadcrumb /
|
|
3218
3338
|
* page-title use. Falls back through `recordTitleAttribute` →
|
|
3219
3339
|
* `name` → `title` → primary key value → 'Record'. */
|
|
@@ -4178,7 +4298,7 @@ export async function resourceViewData(
|
|
|
4178
4298
|
|
|
4179
4299
|
// Plan #11 — prepend the relation tabs strip with the "Details" tab
|
|
4180
4300
|
// active when the resource has relation managers configured.
|
|
4181
|
-
const relationTabsEl = buildRelationTabs(R, recordId, cfg.path, '__view')
|
|
4301
|
+
const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, '__view', user, record)
|
|
4182
4302
|
if (relationTabsEl) elements.unshift(relationTabsEl)
|
|
4183
4303
|
|
|
4184
4304
|
const recordTitle = record !== undefined && record !== null
|
|
@@ -4210,6 +4330,102 @@ export async function resourceViewData(
|
|
|
4210
4330
|
}
|
|
4211
4331
|
}
|
|
4212
4332
|
|
|
4333
|
+
/**
|
|
4334
|
+
* Custom record sub-page data builder. Mounted at
|
|
4335
|
+
* `${resourceBase}/${slug}/:id/${subPageSlug}` for each entry in
|
|
4336
|
+
* `Resource.pages().record`. Mirrors `resourceViewData`'s shape: load
|
|
4337
|
+
* the record, run R.canAccess + R.canView (parent-resource gates),
|
|
4338
|
+
* then SubPage.canAccess(user, record) (sub-page-specific gate),
|
|
4339
|
+
* then render the sub-page's schema with `ctx.record` set. Tab strip
|
|
4340
|
+
* carries the sub-page slug as the active key so the matching record
|
|
4341
|
+
* sub-page tab highlights.
|
|
4342
|
+
*
|
|
4343
|
+
* Returns:
|
|
4344
|
+
* - `null` — resource / sub-page slug not found (404 upstream).
|
|
4345
|
+
* - `{ ok: false, status: 403 }` — any gate fails or throws.
|
|
4346
|
+
* - resolved page data — on success.
|
|
4347
|
+
*/
|
|
4348
|
+
export async function resourceRecordPageData(
|
|
4349
|
+
pilotiq: Pilotiq,
|
|
4350
|
+
slug: string,
|
|
4351
|
+
recordId: string,
|
|
4352
|
+
subPageSlug: string,
|
|
4353
|
+
req?: unknown,
|
|
4354
|
+
): Promise<Record<string, unknown> | null | { ok: false; status: 403 }> {
|
|
4355
|
+
const cfg = pilotiq.getConfig()
|
|
4356
|
+
const R = cfg.resources.find(r => r.getSlug() === slug)
|
|
4357
|
+
if (!R) return null
|
|
4358
|
+
const recordPages = R.getRecordPages()
|
|
4359
|
+
const PageClass = recordPages[subPageSlug]
|
|
4360
|
+
if (!PageClass) return null
|
|
4361
|
+
|
|
4362
|
+
const user = await pilotiq.resolveUser(req)
|
|
4363
|
+
|
|
4364
|
+
// Load the parent record before gating so canView / SubPage.canAccess
|
|
4365
|
+
// can branch on record state. Sub-pages without a Resource.model
|
|
4366
|
+
// still get gated against an `undefined` record — the same posture as
|
|
4367
|
+
// resourceViewData when no model is bound.
|
|
4368
|
+
let record: unknown = undefined
|
|
4369
|
+
if (R.model) {
|
|
4370
|
+
try { record = await findRecord(R, recordId, { user }) } catch { /* ignore */ }
|
|
4371
|
+
}
|
|
4372
|
+
if (record === undefined || record === null) {
|
|
4373
|
+
// Distinguish "model bound but record missing" (route should 404)
|
|
4374
|
+
// from "no model bound" (treat record as `{ id: recordId }` so the
|
|
4375
|
+
// page can still render — same convention as the edit page).
|
|
4376
|
+
if (R.model) return null
|
|
4377
|
+
record = { id: recordId }
|
|
4378
|
+
}
|
|
4379
|
+
|
|
4380
|
+
// Three gates: parent resource access + view, then the sub-page's own
|
|
4381
|
+
// canAccess. The route would have run R.canAccess upstream, but
|
|
4382
|
+
// re-running here makes resourceRecordPageData safe to call from
|
|
4383
|
+
// dispatchPageData (where the SPA path skips the route prelude).
|
|
4384
|
+
if (!await safeBool(() => R.canAccess(user))) return { ok: false, status: 403 }
|
|
4385
|
+
if (!await safeBool(() => R.canView(user, record))) return { ok: false, status: 403 }
|
|
4386
|
+
if (!await safeBool(() => PageClass.canAccess(user, record))) return { ok: false, status: 403 }
|
|
4387
|
+
|
|
4388
|
+
const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'view', recordId, basePath: cfg.path }, user), cfg)
|
|
4389
|
+
const elements = await callPageSchema(PageClass, ctx)
|
|
4390
|
+
|
|
4391
|
+
// Insert the relation-tabs strip with the sub-page slug active so the
|
|
4392
|
+
// matching tab highlights. `buildRelationTabs` evaluates per-tab
|
|
4393
|
+
// gating against `user + record` — record sub-page tabs are gated
|
|
4394
|
+
// alongside __view/__edit/managers.
|
|
4395
|
+
const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, subPageSlug, user, record)
|
|
4396
|
+
if (relationTabsEl) elements.unshift(relationTabsEl)
|
|
4397
|
+
|
|
4398
|
+
const recordTitle = record !== undefined && record !== null
|
|
4399
|
+
? deriveParentTitle(R, record)
|
|
4400
|
+
: recordId
|
|
4401
|
+
const breadcrumbs = resourceViewBreadcrumbs(cfg, R, recordTitle)
|
|
4402
|
+
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
4403
|
+
|
|
4404
|
+
const recordPageRoute: PanelInfoRoute = { resource: R, page: PageClass, recordId }
|
|
4405
|
+
const schemaData = await applyRoleHooks(
|
|
4406
|
+
pilotiq, user, 'view',
|
|
4407
|
+
await resolveSchema(
|
|
4408
|
+
elements,
|
|
4409
|
+
record !== undefined ? { ...ctx, record } : ctx,
|
|
4410
|
+
),
|
|
4411
|
+
recordPageRoute,
|
|
4412
|
+
)
|
|
4413
|
+
|
|
4414
|
+
return {
|
|
4415
|
+
pageType: 'record-page' as const,
|
|
4416
|
+
panel: await panelInfo(pilotiq, req, recordPageRoute),
|
|
4417
|
+
page: PageClass.toMeta(),
|
|
4418
|
+
resource: { name: R.name, label: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
|
|
4419
|
+
mode: 'record' as const,
|
|
4420
|
+
recordId,
|
|
4421
|
+
subPage: { slug: subPageSlug, label: PageClass.getLabel() },
|
|
4422
|
+
basePath: cfg.path,
|
|
4423
|
+
layout: cfg.layout,
|
|
4424
|
+
schemaData,
|
|
4425
|
+
notifications: consumeFlashedNotifications(req),
|
|
4426
|
+
}
|
|
4427
|
+
}
|
|
4428
|
+
|
|
4213
4429
|
export async function globalEditData(
|
|
4214
4430
|
pilotiq: Pilotiq,
|
|
4215
4431
|
slug: string,
|
|
@@ -4606,9 +4822,13 @@ export async function dispatchPageData(pageContext: PageContextLike): Promise<un
|
|
|
4606
4822
|
})
|
|
4607
4823
|
// Tagged failure shapes (`{ ok: false, status: 403 }`) leak straight
|
|
4608
4824
|
// through to the +Page renderer, which can branch on the shape.
|
|
4609
|
-
//
|
|
4610
|
-
//
|
|
4611
|
-
|
|
4825
|
+
// null = no manager named `relationship` on R; fall through to the
|
|
4826
|
+
// record sub-page lookup so URLs like `/admin/users/u1/activity`
|
|
4827
|
+
// (where `activity` is registered under `pages().record`) route
|
|
4828
|
+
// through `resourceRecordPageData` rather than 404ing.
|
|
4829
|
+
if (out !== null) return out as Record<string, unknown>
|
|
4830
|
+
const recordOut = await resourceRecordPageData(panel, slug, id, relationship)
|
|
4831
|
+
return recordOut === null ? null : (recordOut as Record<string, unknown>)
|
|
4612
4832
|
}
|
|
4613
4833
|
|
|
4614
4834
|
case '/pages/(pilotiq)/relation-create': {
|
package/src/react/AppShell.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from 'react'
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
2
|
import { SidebarLayout } from './layouts/SidebarLayout.js'
|
|
3
3
|
import { TopbarLayout } from './layouts/TopbarLayout.js'
|
|
4
4
|
import { ToasterProvider } from './Toaster.js'
|
|
@@ -38,6 +38,11 @@ export interface AppShellProps {
|
|
|
38
38
|
* `panelInfo()` server-side. */
|
|
39
39
|
renderHooks?: RenderHookMap
|
|
40
40
|
themeEditor?: boolean
|
|
41
|
+
/** AI suggestion mode — absent means `'auto'` (the default). When
|
|
42
|
+
* set to `'review'`, AI plugins read this and stage writes as
|
|
43
|
+
* `PendingSuggestion`s for user approval instead of applying
|
|
44
|
+
* immediately. Plan: `docs/plans/ai-review-mode.md`. */
|
|
45
|
+
aiSuggestionsMode?: 'auto' | 'review'
|
|
41
46
|
}
|
|
42
47
|
basePath: string
|
|
43
48
|
/** Pathname used to compute active-link state in the sidebar/topbar. */
|
|
@@ -60,14 +65,32 @@ export interface AppShellProps {
|
|
|
60
65
|
* chrome simply doesn't mount.
|
|
61
66
|
*/
|
|
62
67
|
rightPanelRegistry?: RightPanelRegistry
|
|
68
|
+
/**
|
|
69
|
+
* Build-time layout-provider registry from the Vite plugin. Each entry
|
|
70
|
+
* is a React component that wraps the panel's layout tree at the
|
|
71
|
+
* root. Plugins register via `Pilotiq.layoutProvider(C)`; the Vite
|
|
72
|
+
* plugin harvests refs into this array. Empty `[]` is the no-op
|
|
73
|
+
* default — chrome renders without any extra wrapping.
|
|
74
|
+
*/
|
|
75
|
+
layoutProviderRegistry?: ReadonlyArray<React.ComponentType<{ children: React.ReactNode; basePath?: string }>>
|
|
63
76
|
children: React.ReactNode
|
|
64
77
|
}
|
|
65
78
|
|
|
66
|
-
export function AppShell({ layout = 'sidebar', notifications, componentRegistry, rightPanelRegistry, ...props }: AppShellProps) {
|
|
79
|
+
export function AppShell({ layout = 'sidebar', notifications, componentRegistry, rightPanelRegistry, layoutProviderRegistry, ...props }: AppShellProps) {
|
|
67
80
|
const Layout = layout === 'topbar' ? TopbarLayout : SidebarLayout
|
|
68
81
|
// exactOptionalPropertyTypes: only spread `initialNotifications` when set.
|
|
69
82
|
const toasterProps = notifications ? { initialNotifications: notifications } : {}
|
|
70
83
|
|
|
84
|
+
// Stamp the panel-wide AI suggestion mode on a window global so the
|
|
85
|
+
// AI plugin's `update_form_state` client-tool handler can read it
|
|
86
|
+
// without context plumbing. Singleton flag — doesn't change between
|
|
87
|
+
// pages within the same panel. Plan: `docs/plans/ai-review-mode.md`.
|
|
88
|
+
const aiSuggestionsMode = props.panel.aiSuggestionsMode ?? 'auto'
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (typeof window === 'undefined') return
|
|
91
|
+
;(window as unknown as { __pilotiqAiSuggestionsMode?: 'auto' | 'review' }).__pilotiqAiSuggestionsMode = aiSuggestionsMode
|
|
92
|
+
}, [aiSuggestionsMode])
|
|
93
|
+
|
|
71
94
|
// Plan #12 — palette open state lives at AppShell so the trigger pill
|
|
72
95
|
// (rendered inside the layout's header) and the palette dialog both
|
|
73
96
|
// observe the same flag via context.
|
|
@@ -106,7 +129,11 @@ export function AppShell({ layout = 'sidebar', notifications, componentRegistry,
|
|
|
106
129
|
</ToasterProvider>
|
|
107
130
|
)
|
|
108
131
|
|
|
109
|
-
|
|
132
|
+
// Plugin-registered layout providers (e.g. AI chat queue, tenant
|
|
133
|
+
// theme switcher). Wraps in registration order: the FIRST registered
|
|
134
|
+
// provider sits OUTERMOST (closest to the layout root); LAST sits
|
|
135
|
+
// INNERMOST (closest to the page tree). Empty / unset → no wrap.
|
|
136
|
+
const wrapped = wrapInLayoutProviders(
|
|
110
137
|
<ComponentRegistryProvider value={componentRegistry}>
|
|
111
138
|
<RightPanelRegistryProvider value={rightPanelRegistry}>
|
|
112
139
|
{rightSidebarMeta ? (
|
|
@@ -117,8 +144,32 @@ export function AppShell({ layout = 'sidebar', notifications, componentRegistry,
|
|
|
117
144
|
inner
|
|
118
145
|
)}
|
|
119
146
|
</RightPanelRegistryProvider>
|
|
120
|
-
</ComponentRegistryProvider
|
|
147
|
+
</ComponentRegistryProvider>,
|
|
148
|
+
layoutProviderRegistry,
|
|
149
|
+
props.basePath,
|
|
121
150
|
)
|
|
151
|
+
|
|
152
|
+
return wrapped
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Fold registered layout providers around `tree` from last to first so
|
|
157
|
+
* the first-registered provider ends up outermost in the React tree
|
|
158
|
+
* (closest to the layout root) — matches the registration-order
|
|
159
|
+
* intuition documented on `Pilotiq.layoutProvider`.
|
|
160
|
+
*/
|
|
161
|
+
function wrapInLayoutProviders(
|
|
162
|
+
tree: React.ReactElement,
|
|
163
|
+
registry: ReadonlyArray<React.ComponentType<{ children: React.ReactNode; basePath?: string }>> | undefined,
|
|
164
|
+
basePath: string,
|
|
165
|
+
): React.ReactElement {
|
|
166
|
+
if (!registry || registry.length === 0) return tree
|
|
167
|
+
let acc: React.ReactElement = tree
|
|
168
|
+
for (let i = registry.length - 1; i >= 0; i--) {
|
|
169
|
+
const Provider = registry[i]!
|
|
170
|
+
acc = <Provider basePath={basePath}>{acc}</Provider>
|
|
171
|
+
}
|
|
172
|
+
return acc
|
|
122
173
|
}
|
|
123
174
|
|
|
124
175
|
/**
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { PendingSuggestion } from './PendingSuggestionsContext.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A function that applies a `PendingSuggestion` to its target field —
|
|
5
|
+
* registered by the field renderer (or editor adapter) on mount, looked
|
|
6
|
+
* up by aggregate consumers (e.g. a chat-sidebar pending-pill's
|
|
7
|
+
* "Approve all" button) that live outside the form's React tree.
|
|
8
|
+
*
|
|
9
|
+
* The applier is responsible for the apply *only*. Dismissing the
|
|
10
|
+
* suggestion from the queue is the caller's job — the apply path is
|
|
11
|
+
* decoupled from the queue-side bookkeeping so a future Phase that
|
|
12
|
+
* mirrors approvals to a server can do both via different code paths.
|
|
13
|
+
*/
|
|
14
|
+
export type PendingSuggestionApplier = (suggestion: PendingSuggestion) => void
|
|
15
|
+
|
|
16
|
+
interface RegistryEntry {
|
|
17
|
+
formId: string | undefined
|
|
18
|
+
fieldName: string
|
|
19
|
+
apply: PendingSuggestionApplier
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const _entries = new Map<string, RegistryEntry>()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Compose the registry key. `formId` defaults to `'*'` (global form
|
|
26
|
+
* scope) so renderers in non-multi-form pages don't have to thread an
|
|
27
|
+
* id. Form-scoped registrations always win over the wildcard when both
|
|
28
|
+
* exist for the same field name.
|
|
29
|
+
*/
|
|
30
|
+
function keyFor(formId: string | undefined, fieldName: string): string {
|
|
31
|
+
return `${formId ?? '*'}::${fieldName}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Register an applier for `(formId, fieldName)`. Returns an unregister
|
|
36
|
+
* function for `useEffect` cleanup. Re-registering with the same key
|
|
37
|
+
* replaces the previous entry — the most recently mounted renderer
|
|
38
|
+
* wins (typical in multi-instance form scenarios where an old form
|
|
39
|
+
* unmounts after a new one mounts during navigation).
|
|
40
|
+
*/
|
|
41
|
+
export function registerPendingSuggestionApplier(
|
|
42
|
+
formId: string | undefined,
|
|
43
|
+
fieldName: string,
|
|
44
|
+
apply: PendingSuggestionApplier,
|
|
45
|
+
): () => void {
|
|
46
|
+
const key = keyFor(formId, fieldName)
|
|
47
|
+
const entry: RegistryEntry = { formId, fieldName, apply }
|
|
48
|
+
_entries.set(key, entry)
|
|
49
|
+
return () => {
|
|
50
|
+
// Only delete if this entry is still the one we registered — a
|
|
51
|
+
// re-register from another instance may have replaced us.
|
|
52
|
+
if (_entries.get(key) === entry) _entries.delete(key)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Look up an applier for `(formId, fieldName)`. Tries the form-scoped
|
|
58
|
+
* key first; falls back to the wildcard form ('*') so a producer that
|
|
59
|
+
* pushed a suggestion without `formId` still resolves an applier from
|
|
60
|
+
* a single-form page.
|
|
61
|
+
*/
|
|
62
|
+
export function getPendingSuggestionApplier(
|
|
63
|
+
formId: string | undefined,
|
|
64
|
+
fieldName: string,
|
|
65
|
+
): PendingSuggestionApplier | undefined {
|
|
66
|
+
if (formId !== undefined) {
|
|
67
|
+
const scoped = _entries.get(keyFor(formId, fieldName))
|
|
68
|
+
if (scoped) return scoped.apply
|
|
69
|
+
}
|
|
70
|
+
const wild = _entries.get(keyFor(undefined, fieldName))
|
|
71
|
+
return wild?.apply
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Test seam — clear the registry between tests. Not part of the public
|
|
76
|
+
* API.
|
|
77
|
+
*/
|
|
78
|
+
export function _clearAppliersForTests(): void {
|
|
79
|
+
_entries.clear()
|
|
80
|
+
}
|