@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/dist/pageData.js
CHANGED
|
@@ -40,6 +40,12 @@ export async function panelInfo(pilotiq, req, route = {}) {
|
|
|
40
40
|
buildRightSidebarMeta(cfg, user),
|
|
41
41
|
]);
|
|
42
42
|
const databaseNotifications = buildDatabaseNotificationsMeta(cfg, user);
|
|
43
|
+
// AI suggestion mode — sparse: omit when 'auto' (the default) so the
|
|
44
|
+
// wire shape stays minimal for panels that don't opt into review mode.
|
|
45
|
+
// Plugin clients (e.g. @pilotiq-pro/ai's `AiClientToolBindings`) read
|
|
46
|
+
// this to decide whether to apply writes immediately or stage them as
|
|
47
|
+
// PendingSuggestions for user approval.
|
|
48
|
+
const aiSuggestionsMode = pilotiq.getAiSuggestionsMode();
|
|
43
49
|
return {
|
|
44
50
|
name: cfg.name,
|
|
45
51
|
branding: cfg.branding,
|
|
@@ -50,6 +56,7 @@ export async function panelInfo(pilotiq, req, route = {}) {
|
|
|
50
56
|
...(databaseNotifications ? { databaseNotifications } : {}),
|
|
51
57
|
...(rightSidebar ? { rightSidebar } : {}),
|
|
52
58
|
...(Object.keys(renderHooks).length > 0 ? { renderHooks } : {}),
|
|
59
|
+
...(aiSuggestionsMode !== 'auto' ? { aiSuggestionsMode } : {}),
|
|
53
60
|
};
|
|
54
61
|
}
|
|
55
62
|
/**
|
|
@@ -1570,7 +1577,7 @@ export async function resourceEditData(pilotiq, slug, recordId, prefill, req) {
|
|
|
1570
1577
|
// navigation strip so users can drill into each manager's table
|
|
1571
1578
|
// without leaving the parent record context. The "Edit" tab is
|
|
1572
1579
|
// active here.
|
|
1573
|
-
const relationTabsEl = buildRelationTabs(R, recordId, cfg.path, '__edit');
|
|
1580
|
+
const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, '__edit', user, record);
|
|
1574
1581
|
if (relationTabsEl)
|
|
1575
1582
|
elements.unshift(relationTabsEl);
|
|
1576
1583
|
const recordTitle = record !== undefined && record !== null
|
|
@@ -1832,7 +1839,7 @@ async function buildRelationListData(pilotiq, R, M, Related, parentRecord, scope
|
|
|
1832
1839
|
const elements = [table];
|
|
1833
1840
|
tagActionDispatch(elements, listUrl);
|
|
1834
1841
|
await loadTableRecords(elements, scope.query ?? {}, listUrl, user);
|
|
1835
|
-
const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship);
|
|
1842
|
+
const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord);
|
|
1836
1843
|
if (tabs)
|
|
1837
1844
|
elements.unshift(tabs);
|
|
1838
1845
|
const breadcrumbs = relationListBreadcrumbs(cfg, R, M, scope.recordId, deriveParentTitle(R, parentRecord));
|
|
@@ -1889,7 +1896,7 @@ async function buildRelationCreateData(pilotiq, R, M, Related, parentRecord, sco
|
|
|
1889
1896
|
if (scope.prefill.errors)
|
|
1890
1897
|
form.withErrors(scope.prefill.errors);
|
|
1891
1898
|
}
|
|
1892
|
-
const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship);
|
|
1899
|
+
const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord);
|
|
1893
1900
|
if (tabs)
|
|
1894
1901
|
elements.unshift(tabs);
|
|
1895
1902
|
const breadcrumbs = relationCreateBreadcrumbs(cfg, R, M, scope.recordId, deriveParentTitle(R, parentRecord));
|
|
@@ -1956,10 +1963,10 @@ async function buildRelationViewData(pilotiq, R, M, Related, parentRecord, scope
|
|
|
1956
1963
|
// view straight into a grandchild list / create / view / edit page.
|
|
1957
1964
|
// Active key `'__view'` because the user is currently viewing the
|
|
1958
1965
|
// leaf parent record itself, not any nested manager.
|
|
1959
|
-
const nestedTabs = buildNestedRelationTabs(R, M, base, { recordId: scope.recordId, relationship: scope.relationship }, scope.childId, '__view');
|
|
1966
|
+
const nestedTabs = await buildNestedRelationTabs(R, M, base, { recordId: scope.recordId, relationship: scope.relationship }, scope.childId, '__view', user, child);
|
|
1960
1967
|
if (nestedTabs)
|
|
1961
1968
|
elements.unshift(nestedTabs);
|
|
1962
|
-
const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship);
|
|
1969
|
+
const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord);
|
|
1963
1970
|
if (tabs)
|
|
1964
1971
|
elements.unshift(tabs);
|
|
1965
1972
|
const breadcrumbs = relationViewBreadcrumbs(cfg, R, M, scope.recordId, deriveParentTitle(R, parentRecord), deriveParentTitle(Related, child, M));
|
|
@@ -2041,7 +2048,7 @@ async function buildRelationEditData(pilotiq, R, M, Related, parentRecord, scope
|
|
|
2041
2048
|
const values = await applyFillPipeline(form, child);
|
|
2042
2049
|
form.withValues(values);
|
|
2043
2050
|
}
|
|
2044
|
-
const tabs = buildRelationTabs(R, scope.recordId, base, scope.relationship);
|
|
2051
|
+
const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord);
|
|
2045
2052
|
if (tabs)
|
|
2046
2053
|
elements.unshift(tabs);
|
|
2047
2054
|
const breadcrumbs = relationEditBreadcrumbs(cfg, R, M, scope.recordId, deriveParentTitle(R, parentRecord), scope.childId, deriveParentTitle(Related, child, M));
|
|
@@ -2272,7 +2279,7 @@ async function buildNestedRelationListData(pilotiq, scope, resolved, req, user)
|
|
|
2272
2279
|
const elements = [table];
|
|
2273
2280
|
tagActionDispatch(elements, listUrl);
|
|
2274
2281
|
await loadTableRecords(elements, scope.query ?? {}, listUrl, user);
|
|
2275
|
-
const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship);
|
|
2282
|
+
const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1);
|
|
2276
2283
|
if (tabs)
|
|
2277
2284
|
elements.unshift(tabs);
|
|
2278
2285
|
const breadcrumbs = nestedRelationListBreadcrumbs(cfg, resolved.R, resolved.M1, M2, scope.chain[0], deriveParentTitle(resolved.R, resolved.parentRecord), scope.chain[1].recordId, deriveParentTitle(Related1, child1, resolved.M1));
|
|
@@ -2307,7 +2314,7 @@ async function buildNestedRelationCreateData(pilotiq, scope, resolved, req, user
|
|
|
2307
2314
|
if (scope.prefill.errors)
|
|
2308
2315
|
form.withErrors(scope.prefill.errors);
|
|
2309
2316
|
}
|
|
2310
|
-
const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship);
|
|
2317
|
+
const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1);
|
|
2311
2318
|
if (tabs)
|
|
2312
2319
|
elements.unshift(tabs);
|
|
2313
2320
|
const breadcrumbs = nestedRelationCreateBreadcrumbs(cfg, resolved.R, resolved.M1, M2, scope.chain[0], deriveParentTitle(resolved.R, resolved.parentRecord), scope.chain[1].recordId, deriveParentTitle(resolved.Related1, child1, resolved.M1));
|
|
@@ -2347,7 +2354,7 @@ async function buildNestedRelationViewData(pilotiq, scope, resolved, req, user)
|
|
|
2347
2354
|
const cfg = pilotiq.getConfig();
|
|
2348
2355
|
const base = cfg.path;
|
|
2349
2356
|
const elements = M2.detail(child2, child1);
|
|
2350
|
-
const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship);
|
|
2357
|
+
const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1);
|
|
2351
2358
|
if (tabs)
|
|
2352
2359
|
elements.unshift(tabs);
|
|
2353
2360
|
const breadcrumbs = nestedRelationViewBreadcrumbs(cfg, resolved.R, resolved.M1, M2, scope.chain[0], deriveParentTitle(resolved.R, resolved.parentRecord), scope.chain[1].recordId, deriveParentTitle(Related1, child1, resolved.M1), deriveParentTitle(Related2, child2, M2));
|
|
@@ -2403,7 +2410,7 @@ async function buildNestedRelationEditData(pilotiq, scope, resolved, req, user)
|
|
|
2403
2410
|
const values = await applyFillPipeline(form, child2);
|
|
2404
2411
|
form.withValues(values);
|
|
2405
2412
|
}
|
|
2406
|
-
const tabs = buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship);
|
|
2413
|
+
const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1);
|
|
2407
2414
|
if (tabs)
|
|
2408
2415
|
elements.unshift(tabs);
|
|
2409
2416
|
const breadcrumbs = nestedRelationEditBreadcrumbs(cfg, resolved.R, resolved.M1, M2, scope.chain[0], deriveParentTitle(resolved.R, resolved.parentRecord), scope.chain[1].recordId, deriveParentTitle(Related1, child1, resolved.M1), scope.childId, deriveParentTitle(Related2, child2, M2));
|
|
@@ -2436,13 +2443,27 @@ async function buildNestedRelationEditData(pilotiq, scope, resolved, req, user)
|
|
|
2436
2443
|
* absent that, callers skip the prepend so single-manager surfaces stay
|
|
2437
2444
|
* clean. `activeKey` accepts the literal `'__view'` for the leaf
|
|
2438
2445
|
* parent's view tab, or any sibling manager's relationship key.
|
|
2446
|
+
*
|
|
2447
|
+
* Per-tab `canX` gating (2026-05-11) — sibling nested-manager tabs run
|
|
2448
|
+
* `N.canViewAny(user, child1Record)` (with fall-through to the related
|
|
2449
|
+
* Resource via `safeManagerPolicy`) so the strip hides tabs the user
|
|
2450
|
+
* couldn't reach anyway. The back-link `__view` stays unconditional
|
|
2451
|
+
* since the user is already on a page scoped under `M.canViewAny` —
|
|
2452
|
+
* they reached this strip, they can navigate back to it.
|
|
2439
2453
|
*/
|
|
2440
|
-
function buildNestedRelationTabs(R, M, basePath, step0, child1Id, activeKey) {
|
|
2454
|
+
async function buildNestedRelationTabs(R, M, basePath, step0, child1Id, activeKey, user, child1Record) {
|
|
2441
2455
|
const siblings = M.relations();
|
|
2442
2456
|
if (siblings.length === 0)
|
|
2443
2457
|
return undefined;
|
|
2444
2458
|
const resourceBase = resourceBasePath(basePath, R);
|
|
2445
2459
|
const parentBase = `${resourceBase}/${step0.recordId}/${step0.relationship}`;
|
|
2460
|
+
// Sibling gating runs in parallel — each predicate may hit auth /
|
|
2461
|
+
// db, so don't serialize them.
|
|
2462
|
+
const siblingGates = siblings.map(N => {
|
|
2463
|
+
const Related = N.relatedResource;
|
|
2464
|
+
return safeManagerPolicyImpl(N, 'canViewAny', Related, user, child1Record);
|
|
2465
|
+
});
|
|
2466
|
+
const siblingVisible = await Promise.all(siblingGates);
|
|
2446
2467
|
const tabs = [];
|
|
2447
2468
|
// Back-link: depth-2 view page for the leaf parent record. Acts as
|
|
2448
2469
|
// the "View" tab in the same way `__view` does on depth-1 strips.
|
|
@@ -2454,13 +2475,15 @@ function buildNestedRelationTabs(R, M, basePath, step0, child1Id, activeKey) {
|
|
|
2454
2475
|
icon: M.getIcon(),
|
|
2455
2476
|
iconOwner: M.name,
|
|
2456
2477
|
}));
|
|
2457
|
-
|
|
2478
|
+
siblings.forEach((N, i) => {
|
|
2479
|
+
if (!siblingVisible[i])
|
|
2480
|
+
return;
|
|
2458
2481
|
let nestedRel = '';
|
|
2459
2482
|
try {
|
|
2460
2483
|
nestedRel = N.getRelationship();
|
|
2461
2484
|
}
|
|
2462
2485
|
catch {
|
|
2463
|
-
|
|
2486
|
+
return;
|
|
2464
2487
|
}
|
|
2465
2488
|
const icon = N.getIcon();
|
|
2466
2489
|
tabs.push(relationTab({
|
|
@@ -2470,7 +2493,12 @@ function buildNestedRelationTabs(R, M, basePath, step0, child1Id, activeKey) {
|
|
|
2470
2493
|
active: activeKey === nestedRel,
|
|
2471
2494
|
...(icon !== undefined ? { icon, iconOwner: N.name } : {}),
|
|
2472
2495
|
}));
|
|
2473
|
-
}
|
|
2496
|
+
});
|
|
2497
|
+
// After gating, only the back-link may remain — one tab isn't a
|
|
2498
|
+
// useful sub-nav. Drop the strip in that case (consistent with
|
|
2499
|
+
// depth-1's empty-tabs branch).
|
|
2500
|
+
if (tabs.length <= 1)
|
|
2501
|
+
return undefined;
|
|
2474
2502
|
return RelationTabs.make(tabs);
|
|
2475
2503
|
}
|
|
2476
2504
|
/**
|
|
@@ -2481,11 +2509,25 @@ function buildNestedRelationTabs(R, M, basePath, step0, child1Id, activeKey) {
|
|
|
2481
2509
|
* the manager's relationship key for a manager tab.
|
|
2482
2510
|
*
|
|
2483
2511
|
* Sub-nav follow-up (2026-05-03 cont'd) — emit BOTH `__view` and
|
|
2484
|
-
* `__edit` as sibling tabs (
|
|
2485
|
-
*
|
|
2486
|
-
*
|
|
2487
|
-
*
|
|
2488
|
-
*
|
|
2512
|
+
* `__edit` as sibling tabs (record sub-navigation) instead of one
|
|
2513
|
+
* parent tab whose label depends on mode. Tabs are dropped when the
|
|
2514
|
+
* corresponding page role isn't registered (a Resource overriding
|
|
2515
|
+
* `pages()` to omit `view` or `edit` shouldn't surface a tab that
|
|
2516
|
+
* 404s).
|
|
2517
|
+
*
|
|
2518
|
+
* Per-tab `canX` gating (2026-05-11) — the strip now also evaluates
|
|
2519
|
+
* the matching predicate for each tab and drops the entry when the
|
|
2520
|
+
* user can't reach it. Routes still enforce; this is presentation
|
|
2521
|
+
* polish so the chrome doesn't promise a link that 403s on click.
|
|
2522
|
+
*
|
|
2523
|
+
* - `__view` → `R.canView(user, parentRecord)` (skip gating when
|
|
2524
|
+
* `parentRecord` is undefined — record load failed,
|
|
2525
|
+
* so the route's own load+gate will surface a 404/403
|
|
2526
|
+
* rather than the strip hiding silently).
|
|
2527
|
+
* - `__edit` → `R.canEdit(user, parentRecord)` (same posture).
|
|
2528
|
+
* - manager → `safeManagerPolicy(M, 'canViewAny', Related, user,
|
|
2529
|
+
* parentRecord)` (falls through to Related's
|
|
2530
|
+
* `canViewAny` when the manager hasn't overridden).
|
|
2489
2531
|
*
|
|
2490
2532
|
* Returns `undefined` when the resource has no relation managers — the
|
|
2491
2533
|
* caller can then skip the prepend entirely so resources without
|
|
@@ -2493,17 +2535,55 @@ function buildNestedRelationTabs(R, M, basePath, step0, child1Id, activeKey) {
|
|
|
2493
2535
|
* (View+Edit sub-nav alone isn't worth a tab strip; users navigate
|
|
2494
2536
|
* those via headerActions or the back link.)
|
|
2495
2537
|
*/
|
|
2496
|
-
function buildRelationTabs(R, recordId, basePath, activeKey) {
|
|
2538
|
+
async function buildRelationTabs(R, recordId, basePath, activeKey, user, parentRecord) {
|
|
2497
2539
|
const managers = R.relations();
|
|
2498
|
-
|
|
2540
|
+
const recordPageMap = R.getRecordPages();
|
|
2541
|
+
const recordPageSlugs = Object.keys(recordPageMap);
|
|
2542
|
+
// No managers AND no record sub-pages → no strip. View+Edit alone
|
|
2543
|
+
// isn't worth a tab strip; users navigate those via headerActions or
|
|
2544
|
+
// the back link. (When either is non-empty, the strip is worth
|
|
2545
|
+
// mounting even if all the dynamic tabs end up gated away — the
|
|
2546
|
+
// post-gate emptiness check below catches that.)
|
|
2547
|
+
if (managers.length === 0 && recordPageSlugs.length === 0)
|
|
2499
2548
|
return undefined;
|
|
2500
2549
|
const resourceBase = resourceBasePath(basePath, R);
|
|
2501
2550
|
const pages = R.resolvePages();
|
|
2551
|
+
// Evaluate every per-tab predicate in parallel. The arrays line up
|
|
2552
|
+
// 1:1 with `pages.view` / `pages.edit` / `recordPageSlugs` /
|
|
2553
|
+
// `managers` below — we resolve all gates first so the tab-build
|
|
2554
|
+
// loop stays straight-line.
|
|
2555
|
+
// Record-aware predicates short-circuit to `true` when no parent
|
|
2556
|
+
// record was loaded (presentation should never hide more aggressively
|
|
2557
|
+
// than the route can enforce; a missing record means the route will
|
|
2558
|
+
// 404/403 on click and the strip stays consistent with that).
|
|
2559
|
+
const canViewPromise = pages.view && parentRecord !== undefined && parentRecord !== null
|
|
2560
|
+
? safeBool(() => R.canView(user, parentRecord))
|
|
2561
|
+
: Promise.resolve(true);
|
|
2562
|
+
const canEditPromise = pages.edit && parentRecord !== undefined && parentRecord !== null
|
|
2563
|
+
? safeBool(() => R.canEdit(user, parentRecord))
|
|
2564
|
+
: Promise.resolve(true);
|
|
2565
|
+
const recordPageGates = recordPageSlugs.map(subSlug => {
|
|
2566
|
+
// Record sub-page gates run against the parent record verbatim —
|
|
2567
|
+
// missing record still calls the predicate so a sub-page that
|
|
2568
|
+
// gates on global user state (no record needed) still evaluates.
|
|
2569
|
+
// safeBool fails closed for throwing predicates.
|
|
2570
|
+
return safeBool(() => recordPageMap[subSlug].canAccess(user, parentRecord));
|
|
2571
|
+
});
|
|
2572
|
+
const managerGates = managers.map(M => {
|
|
2573
|
+
const Related = M.relatedResource;
|
|
2574
|
+
return safeManagerPolicyImpl(M, 'canViewAny', Related, user, parentRecord);
|
|
2575
|
+
});
|
|
2576
|
+
const gateResults = await Promise.all([
|
|
2577
|
+
canViewPromise, canEditPromise,
|
|
2578
|
+
...recordPageGates,
|
|
2579
|
+
...managerGates,
|
|
2580
|
+
]);
|
|
2581
|
+
const canView = gateResults[0];
|
|
2582
|
+
const canEdit = gateResults[1];
|
|
2583
|
+
const recordPageVisible = gateResults.slice(2, 2 + recordPageSlugs.length);
|
|
2584
|
+
const managerVisible = gateResults.slice(2 + recordPageSlugs.length);
|
|
2502
2585
|
const tabs = [];
|
|
2503
|
-
|
|
2504
|
-
// Defaults always include one; users who pruned ViewPage in their
|
|
2505
|
-
// `static pages()` override get no broken link.
|
|
2506
|
-
if (pages.view) {
|
|
2586
|
+
if (pages.view && canView) {
|
|
2507
2587
|
tabs.push(relationTab({
|
|
2508
2588
|
key: '__view',
|
|
2509
2589
|
label: 'View',
|
|
@@ -2513,8 +2593,7 @@ function buildRelationTabs(R, recordId, basePath, activeKey) {
|
|
|
2513
2593
|
iconOwner: R.name,
|
|
2514
2594
|
}));
|
|
2515
2595
|
}
|
|
2516
|
-
|
|
2517
|
-
if (pages.edit) {
|
|
2596
|
+
if (pages.edit && canEdit) {
|
|
2518
2597
|
tabs.push(relationTab({
|
|
2519
2598
|
key: '__edit',
|
|
2520
2599
|
label: 'Edit',
|
|
@@ -2527,13 +2606,34 @@ function buildRelationTabs(R, recordId, basePath, activeKey) {
|
|
|
2527
2606
|
iconOwner: R.name,
|
|
2528
2607
|
}));
|
|
2529
2608
|
}
|
|
2530
|
-
|
|
2609
|
+
// Record sub-page tabs — between Edit and the managers, in declaration
|
|
2610
|
+
// order. Tab label inherits from the sub-page's class (`getLabel()`);
|
|
2611
|
+
// icon picks up the sub-page's static `icon` when set. Slug doubles as
|
|
2612
|
+
// the URL segment AND the `activeKey` discriminator the data builder
|
|
2613
|
+
// passes when rendering the sub-page.
|
|
2614
|
+
recordPageSlugs.forEach((subSlug, i) => {
|
|
2615
|
+
if (!recordPageVisible[i])
|
|
2616
|
+
return;
|
|
2617
|
+
const SubPage = recordPageMap[subSlug];
|
|
2618
|
+
tabs.push(relationTab({
|
|
2619
|
+
key: subSlug,
|
|
2620
|
+
label: SubPage.getLabel(),
|
|
2621
|
+
url: `${resourceBase}/${recordId}/${subSlug}`,
|
|
2622
|
+
active: activeKey === subSlug,
|
|
2623
|
+
...(SubPage.icon !== undefined
|
|
2624
|
+
? { icon: SubPage.icon, iconOwner: SubPage.name }
|
|
2625
|
+
: {}),
|
|
2626
|
+
}));
|
|
2627
|
+
});
|
|
2628
|
+
managers.forEach((M, i) => {
|
|
2629
|
+
if (!managerVisible[i])
|
|
2630
|
+
return;
|
|
2531
2631
|
let rel = '';
|
|
2532
2632
|
try {
|
|
2533
2633
|
rel = M.getRelationship();
|
|
2534
2634
|
}
|
|
2535
2635
|
catch {
|
|
2536
|
-
|
|
2636
|
+
return;
|
|
2537
2637
|
}
|
|
2538
2638
|
const icon = M.getIcon();
|
|
2539
2639
|
tabs.push(relationTab({
|
|
@@ -2543,9 +2643,27 @@ function buildRelationTabs(R, recordId, basePath, activeKey) {
|
|
|
2543
2643
|
active: activeKey === rel,
|
|
2544
2644
|
...(icon !== undefined ? { icon, iconOwner: M.name } : {}),
|
|
2545
2645
|
}));
|
|
2546
|
-
}
|
|
2646
|
+
});
|
|
2647
|
+
// After gating, the strip may collapse to zero entries. Mirror the
|
|
2648
|
+
// "no managers + no sub-pages" branch above — no strip is friendlier
|
|
2649
|
+
// than a one-tab strip with just the active page.
|
|
2650
|
+
if (tabs.length === 0)
|
|
2651
|
+
return undefined;
|
|
2547
2652
|
return RelationTabs.make(tabs);
|
|
2548
2653
|
}
|
|
2654
|
+
/**
|
|
2655
|
+
* Tiny shim over `try { Boolean(await fn()) } catch { false }` so the
|
|
2656
|
+
* relation-tabs builder stays straight-line — mirrors `checkPolicy`
|
|
2657
|
+
* in `routes.ts` but kept local to avoid cross-module imports.
|
|
2658
|
+
*/
|
|
2659
|
+
async function safeBool(fn) {
|
|
2660
|
+
try {
|
|
2661
|
+
return Boolean(await fn());
|
|
2662
|
+
}
|
|
2663
|
+
catch {
|
|
2664
|
+
return false;
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2549
2667
|
/** Pull a human-readable title off a parent record for breadcrumb /
|
|
2550
2668
|
* page-title use. Falls back through `recordTitleAttribute` →
|
|
2551
2669
|
* `name` → `title` → primary key value → 'Record'. */
|
|
@@ -3330,7 +3448,7 @@ export async function resourceViewData(pilotiq, slug, recordId, req) {
|
|
|
3330
3448
|
}
|
|
3331
3449
|
// Plan #11 — prepend the relation tabs strip with the "Details" tab
|
|
3332
3450
|
// active when the resource has relation managers configured.
|
|
3333
|
-
const relationTabsEl = buildRelationTabs(R, recordId, cfg.path, '__view');
|
|
3451
|
+
const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, '__view', user, record);
|
|
3334
3452
|
if (relationTabsEl)
|
|
3335
3453
|
elements.unshift(relationTabsEl);
|
|
3336
3454
|
const recordTitle = record !== undefined && record !== null
|
|
@@ -3353,6 +3471,91 @@ export async function resourceViewData(pilotiq, slug, recordId, req) {
|
|
|
3353
3471
|
notifications: consumeFlashedNotifications(req),
|
|
3354
3472
|
};
|
|
3355
3473
|
}
|
|
3474
|
+
/**
|
|
3475
|
+
* Custom record sub-page data builder. Mounted at
|
|
3476
|
+
* `${resourceBase}/${slug}/:id/${subPageSlug}` for each entry in
|
|
3477
|
+
* `Resource.pages().record`. Mirrors `resourceViewData`'s shape: load
|
|
3478
|
+
* the record, run R.canAccess + R.canView (parent-resource gates),
|
|
3479
|
+
* then SubPage.canAccess(user, record) (sub-page-specific gate),
|
|
3480
|
+
* then render the sub-page's schema with `ctx.record` set. Tab strip
|
|
3481
|
+
* carries the sub-page slug as the active key so the matching record
|
|
3482
|
+
* sub-page tab highlights.
|
|
3483
|
+
*
|
|
3484
|
+
* Returns:
|
|
3485
|
+
* - `null` — resource / sub-page slug not found (404 upstream).
|
|
3486
|
+
* - `{ ok: false, status: 403 }` — any gate fails or throws.
|
|
3487
|
+
* - resolved page data — on success.
|
|
3488
|
+
*/
|
|
3489
|
+
export async function resourceRecordPageData(pilotiq, slug, recordId, subPageSlug, req) {
|
|
3490
|
+
const cfg = pilotiq.getConfig();
|
|
3491
|
+
const R = cfg.resources.find(r => r.getSlug() === slug);
|
|
3492
|
+
if (!R)
|
|
3493
|
+
return null;
|
|
3494
|
+
const recordPages = R.getRecordPages();
|
|
3495
|
+
const PageClass = recordPages[subPageSlug];
|
|
3496
|
+
if (!PageClass)
|
|
3497
|
+
return null;
|
|
3498
|
+
const user = await pilotiq.resolveUser(req);
|
|
3499
|
+
// Load the parent record before gating so canView / SubPage.canAccess
|
|
3500
|
+
// can branch on record state. Sub-pages without a Resource.model
|
|
3501
|
+
// still get gated against an `undefined` record — the same posture as
|
|
3502
|
+
// resourceViewData when no model is bound.
|
|
3503
|
+
let record = undefined;
|
|
3504
|
+
if (R.model) {
|
|
3505
|
+
try {
|
|
3506
|
+
record = await findRecord(R, recordId, { user });
|
|
3507
|
+
}
|
|
3508
|
+
catch { /* ignore */ }
|
|
3509
|
+
}
|
|
3510
|
+
if (record === undefined || record === null) {
|
|
3511
|
+
// Distinguish "model bound but record missing" (route should 404)
|
|
3512
|
+
// from "no model bound" (treat record as `{ id: recordId }` so the
|
|
3513
|
+
// page can still render — same convention as the edit page).
|
|
3514
|
+
if (R.model)
|
|
3515
|
+
return null;
|
|
3516
|
+
record = { id: recordId };
|
|
3517
|
+
}
|
|
3518
|
+
// Three gates: parent resource access + view, then the sub-page's own
|
|
3519
|
+
// canAccess. The route would have run R.canAccess upstream, but
|
|
3520
|
+
// re-running here makes resourceRecordPageData safe to call from
|
|
3521
|
+
// dispatchPageData (where the SPA path skips the route prelude).
|
|
3522
|
+
if (!await safeBool(() => R.canAccess(user)))
|
|
3523
|
+
return { ok: false, status: 403 };
|
|
3524
|
+
if (!await safeBool(() => R.canView(user, record)))
|
|
3525
|
+
return { ok: false, status: 403 };
|
|
3526
|
+
if (!await safeBool(() => PageClass.canAccess(user, record)))
|
|
3527
|
+
return { ok: false, status: 403 };
|
|
3528
|
+
const ctx = uploadCtx(userCtx({ mode: 'view', recordId, basePath: cfg.path }, user), cfg);
|
|
3529
|
+
const elements = await callPageSchema(PageClass, ctx);
|
|
3530
|
+
// Insert the relation-tabs strip with the sub-page slug active so the
|
|
3531
|
+
// matching tab highlights. `buildRelationTabs` evaluates per-tab
|
|
3532
|
+
// gating against `user + record` — record sub-page tabs are gated
|
|
3533
|
+
// alongside __view/__edit/managers.
|
|
3534
|
+
const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, subPageSlug, user, record);
|
|
3535
|
+
if (relationTabsEl)
|
|
3536
|
+
elements.unshift(relationTabsEl);
|
|
3537
|
+
const recordTitle = record !== undefined && record !== null
|
|
3538
|
+
? deriveParentTitle(R, record)
|
|
3539
|
+
: recordId;
|
|
3540
|
+
const breadcrumbs = resourceViewBreadcrumbs(cfg, R, recordTitle);
|
|
3541
|
+
if (breadcrumbs)
|
|
3542
|
+
elements.unshift(breadcrumbs);
|
|
3543
|
+
const recordPageRoute = { resource: R, page: PageClass, recordId };
|
|
3544
|
+
const schemaData = await applyRoleHooks(pilotiq, user, 'view', await resolveSchema(elements, record !== undefined ? { ...ctx, record } : ctx), recordPageRoute);
|
|
3545
|
+
return {
|
|
3546
|
+
pageType: 'record-page',
|
|
3547
|
+
panel: await panelInfo(pilotiq, req, recordPageRoute),
|
|
3548
|
+
page: PageClass.toMeta(),
|
|
3549
|
+
resource: { name: R.name, label: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
|
|
3550
|
+
mode: 'record',
|
|
3551
|
+
recordId,
|
|
3552
|
+
subPage: { slug: subPageSlug, label: PageClass.getLabel() },
|
|
3553
|
+
basePath: cfg.path,
|
|
3554
|
+
layout: cfg.layout,
|
|
3555
|
+
schemaData,
|
|
3556
|
+
notifications: consumeFlashedNotifications(req),
|
|
3557
|
+
};
|
|
3558
|
+
}
|
|
3356
3559
|
export async function globalEditData(pilotiq, slug, prefill, req) {
|
|
3357
3560
|
const cfg = pilotiq.getConfig();
|
|
3358
3561
|
const G = cfg.globals.find(g => g.getSlug() === slug);
|
|
@@ -3662,9 +3865,14 @@ export async function dispatchPageData(pageContext) {
|
|
|
3662
3865
|
});
|
|
3663
3866
|
// Tagged failure shapes (`{ ok: false, status: 403 }`) leak straight
|
|
3664
3867
|
// through to the +Page renderer, which can branch on the shape.
|
|
3665
|
-
//
|
|
3666
|
-
//
|
|
3667
|
-
|
|
3868
|
+
// null = no manager named `relationship` on R; fall through to the
|
|
3869
|
+
// record sub-page lookup so URLs like `/admin/users/u1/activity`
|
|
3870
|
+
// (where `activity` is registered under `pages().record`) route
|
|
3871
|
+
// through `resourceRecordPageData` rather than 404ing.
|
|
3872
|
+
if (out !== null)
|
|
3873
|
+
return out;
|
|
3874
|
+
const recordOut = await resourceRecordPageData(panel, slug, id, relationship);
|
|
3875
|
+
return recordOut === null ? null : recordOut;
|
|
3668
3876
|
}
|
|
3669
3877
|
case '/pages/(pilotiq)/relation-create': {
|
|
3670
3878
|
const slug = routeParams['slug'];
|