@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
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import { describe, it } from 'node:test'
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test'
|
|
2
2
|
import assert from 'node:assert/strict'
|
|
3
3
|
|
|
4
4
|
import { Pilotiq } from './Pilotiq.js'
|
|
5
5
|
import { Resource } from './Resource.js'
|
|
6
|
+
import { Page } from './Page.js'
|
|
6
7
|
import { RelationManager } from './RelationManager.js'
|
|
7
8
|
import { Form } from './elements/Form.js'
|
|
8
9
|
import { Table } from './elements/Table.js'
|
|
9
10
|
import { Column } from './Column.js'
|
|
10
11
|
import { TextField } from './fields/TextField.js'
|
|
11
12
|
import { Heading } from './schema/Heading.js'
|
|
12
|
-
import { findRelatedResource, relationManagerData, dispatchPageData, resourceEditData, resourceViewData, safeManagerPolicy } from './pageData.js'
|
|
13
|
+
import { findRelatedResource, relationManagerData, dispatchPageData, resourceEditData, resourceViewData, resourceRecordPageData, safeManagerPolicy } from './pageData.js'
|
|
13
14
|
import { PilotiqRegistry } from './PilotiqRegistry.js'
|
|
14
15
|
import type { ModelLike, ModelQuery } from './orm/modelDefaults.js'
|
|
15
16
|
|
|
@@ -926,6 +927,208 @@ describe('relation tabs auto-mount (Plan #11)', () => {
|
|
|
926
927
|
const tabs = tabsMeta['tabs'] as Array<{ key: string }>
|
|
927
928
|
assert.deepEqual(tabs.map(t => t.key), ['__view', 'posts'])
|
|
928
929
|
})
|
|
930
|
+
|
|
931
|
+
// ── Per-tab canX gating ──────────────────────────────────
|
|
932
|
+
|
|
933
|
+
it('hides the View tab when R.canView returns false for this record', async () => {
|
|
934
|
+
const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Post One' }]
|
|
935
|
+
const PostModel = stubModel({ rows: postRows })
|
|
936
|
+
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
937
|
+
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
938
|
+
|
|
939
|
+
class PostResource extends Resource {
|
|
940
|
+
static override slug = 'posts'
|
|
941
|
+
static override get model() { return PostModel }
|
|
942
|
+
}
|
|
943
|
+
class PostsManager extends RelationManager {
|
|
944
|
+
static override relationship = 'posts'
|
|
945
|
+
}
|
|
946
|
+
class UserResource extends Resource {
|
|
947
|
+
static override slug = 'users'
|
|
948
|
+
static override get model() { return ParentModel }
|
|
949
|
+
static override detail() { return [] }
|
|
950
|
+
static override relations() { return [PostsManager] }
|
|
951
|
+
static override async canView(): Promise<boolean> { return false }
|
|
952
|
+
}
|
|
953
|
+
const panel = Pilotiq.make('NoCanView-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
954
|
+
|
|
955
|
+
// Use resource-edit so the route doesn't 403 before we render — we
|
|
956
|
+
// want to see the strip itself drop the View tab.
|
|
957
|
+
const out = await resourceEditData(panel, 'users', 'u1')
|
|
958
|
+
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
959
|
+
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
960
|
+
const tabs = tabsMeta['tabs'] as Array<{ key: string }>
|
|
961
|
+
assert.deepEqual(tabs.map(t => t.key), ['__edit', 'posts'])
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
it('hides the Edit tab when R.canEdit returns false for this record', async () => {
|
|
965
|
+
const postRows: QueryRow[] = []
|
|
966
|
+
const PostModel = stubModel({ rows: postRows })
|
|
967
|
+
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
968
|
+
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
969
|
+
|
|
970
|
+
class PostResource extends Resource {
|
|
971
|
+
static override slug = 'posts'
|
|
972
|
+
static override get model() { return PostModel }
|
|
973
|
+
}
|
|
974
|
+
class PostsManager extends RelationManager {
|
|
975
|
+
static override relationship = 'posts'
|
|
976
|
+
}
|
|
977
|
+
class UserResource extends Resource {
|
|
978
|
+
static override slug = 'users'
|
|
979
|
+
static override get model() { return ParentModel }
|
|
980
|
+
static override detail() { return [] }
|
|
981
|
+
static override relations() { return [PostsManager] }
|
|
982
|
+
static override async canEdit(): Promise<boolean> { return false }
|
|
983
|
+
}
|
|
984
|
+
const panel = Pilotiq.make('NoCanEdit-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
985
|
+
|
|
986
|
+
const out = await resourceViewData(panel, 'users', 'u1')
|
|
987
|
+
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
988
|
+
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
989
|
+
const tabs = tabsMeta['tabs'] as Array<{ key: string }>
|
|
990
|
+
assert.deepEqual(tabs.map(t => t.key), ['__view', 'posts'])
|
|
991
|
+
})
|
|
992
|
+
|
|
993
|
+
it('hides a manager tab when M.canViewAny returns false', async () => {
|
|
994
|
+
const postRows: QueryRow[] = []
|
|
995
|
+
const commentRows: QueryRow[] = []
|
|
996
|
+
const PostModel = stubModel({ rows: postRows })
|
|
997
|
+
const CommentModel = stubModel({ rows: commentRows })
|
|
998
|
+
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
999
|
+
Object.assign(ParentModel as object, { relations: {
|
|
1000
|
+
posts: { model: () => PostModel },
|
|
1001
|
+
comments: { model: () => CommentModel },
|
|
1002
|
+
} })
|
|
1003
|
+
|
|
1004
|
+
class PostResource extends Resource {
|
|
1005
|
+
static override slug = 'posts'
|
|
1006
|
+
static override get model() { return PostModel }
|
|
1007
|
+
}
|
|
1008
|
+
class CommentResource extends Resource {
|
|
1009
|
+
static override slug = 'comments'
|
|
1010
|
+
static override get model() { return CommentModel }
|
|
1011
|
+
}
|
|
1012
|
+
class PostsManager extends RelationManager {
|
|
1013
|
+
static override relationship = 'posts'
|
|
1014
|
+
}
|
|
1015
|
+
class CommentsManager extends RelationManager {
|
|
1016
|
+
static override relationship = 'comments'
|
|
1017
|
+
static override async canViewAny(): Promise<boolean> { return false }
|
|
1018
|
+
}
|
|
1019
|
+
class UserResource extends Resource {
|
|
1020
|
+
static override slug = 'users'
|
|
1021
|
+
static override get model() { return ParentModel }
|
|
1022
|
+
static override detail() { return [] }
|
|
1023
|
+
static override relations() { return [PostsManager, CommentsManager] }
|
|
1024
|
+
}
|
|
1025
|
+
const panel = Pilotiq.make('GatedMgr-' + Math.random()).path('/admin').resources([UserResource, PostResource, CommentResource])
|
|
1026
|
+
|
|
1027
|
+
const out = await resourceViewData(panel, 'users', 'u1')
|
|
1028
|
+
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1029
|
+
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
1030
|
+
const tabs = tabsMeta['tabs'] as Array<{ key: string }>
|
|
1031
|
+
// CommentsManager is gone — Posts survives because it inherits the
|
|
1032
|
+
// default `canViewAny → true`.
|
|
1033
|
+
assert.deepEqual(tabs.map(t => t.key), ['__view', '__edit', 'posts'])
|
|
1034
|
+
})
|
|
1035
|
+
|
|
1036
|
+
it('falls through to Related.canViewAny when manager has not overridden', async () => {
|
|
1037
|
+
const postRows: QueryRow[] = []
|
|
1038
|
+
const PostModel = stubModel({ rows: postRows })
|
|
1039
|
+
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
1040
|
+
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
1041
|
+
|
|
1042
|
+
class PostResource extends Resource {
|
|
1043
|
+
static override slug = 'posts'
|
|
1044
|
+
static override get model() { return PostModel }
|
|
1045
|
+
// Related-side gate fires through safeManagerPolicy fall-through
|
|
1046
|
+
// since PostsManager doesn't override canViewAny.
|
|
1047
|
+
static override async canViewAny(): Promise<boolean> { return false }
|
|
1048
|
+
}
|
|
1049
|
+
class PostsManager extends RelationManager {
|
|
1050
|
+
static override relationship = 'posts'
|
|
1051
|
+
static override relatedResource = PostResource
|
|
1052
|
+
}
|
|
1053
|
+
class UserResource extends Resource {
|
|
1054
|
+
static override slug = 'users'
|
|
1055
|
+
static override get model() { return ParentModel }
|
|
1056
|
+
static override detail() { return [] }
|
|
1057
|
+
static override relations() { return [PostsManager] }
|
|
1058
|
+
}
|
|
1059
|
+
const panel = Pilotiq.make('RelatedGate-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
1060
|
+
|
|
1061
|
+
const out = await resourceViewData(panel, 'users', 'u1')
|
|
1062
|
+
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1063
|
+
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown> | undefined
|
|
1064
|
+
// With Posts gone, only the parent View+Edit tabs survive. The
|
|
1065
|
+
// strip drops to under 2 manager-able entries so it stays mounted
|
|
1066
|
+
// (View+Edit isn't worth-it; the depth-1 code path keeps the strip
|
|
1067
|
+
// because the dropped tab was a manager, not a parent tab).
|
|
1068
|
+
const tabs = (tabsMeta?.['tabs'] as Array<{ key: string }>) ?? []
|
|
1069
|
+
assert.equal(tabs.find(t => t.key === 'posts'), undefined)
|
|
1070
|
+
})
|
|
1071
|
+
|
|
1072
|
+
it('throwing canX predicate fails closed (tab hidden)', async () => {
|
|
1073
|
+
const postRows: QueryRow[] = []
|
|
1074
|
+
const PostModel = stubModel({ rows: postRows })
|
|
1075
|
+
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
1076
|
+
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
1077
|
+
|
|
1078
|
+
class PostResource extends Resource {
|
|
1079
|
+
static override slug = 'posts'
|
|
1080
|
+
static override get model() { return PostModel }
|
|
1081
|
+
}
|
|
1082
|
+
class PostsManager extends RelationManager {
|
|
1083
|
+
static override relationship = 'posts'
|
|
1084
|
+
}
|
|
1085
|
+
class UserResource extends Resource {
|
|
1086
|
+
static override slug = 'users'
|
|
1087
|
+
static override get model() { return ParentModel }
|
|
1088
|
+
static override detail() { return [] }
|
|
1089
|
+
static override relations() { return [PostsManager] }
|
|
1090
|
+
static override async canView(): Promise<boolean> { throw new Error('boom') }
|
|
1091
|
+
}
|
|
1092
|
+
const panel = Pilotiq.make('ThrowCanView-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
1093
|
+
|
|
1094
|
+
const out = await resourceEditData(panel, 'users', 'u1')
|
|
1095
|
+
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1096
|
+
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
1097
|
+
const tabs = tabsMeta['tabs'] as Array<{ key: string }>
|
|
1098
|
+
// canView threw → fail closed (hidden). canEdit + Posts survive.
|
|
1099
|
+
assert.deepEqual(tabs.map(t => t.key), ['__edit', 'posts'])
|
|
1100
|
+
})
|
|
1101
|
+
|
|
1102
|
+
it('drops the strip entirely when every manager tab is gated away on the View page', async () => {
|
|
1103
|
+
const postRows: QueryRow[] = []
|
|
1104
|
+
const PostModel = stubModel({ rows: postRows })
|
|
1105
|
+
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
1106
|
+
Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
|
|
1107
|
+
|
|
1108
|
+
class PostResource extends Resource {
|
|
1109
|
+
static override slug = 'posts'
|
|
1110
|
+
static override get model() { return PostModel }
|
|
1111
|
+
}
|
|
1112
|
+
class PostsManager extends RelationManager {
|
|
1113
|
+
static override relationship = 'posts'
|
|
1114
|
+
static override async canViewAny(): Promise<boolean> { return false }
|
|
1115
|
+
}
|
|
1116
|
+
class UserResource extends Resource {
|
|
1117
|
+
static override slug = 'users'
|
|
1118
|
+
static override get model() { return ParentModel }
|
|
1119
|
+
static override detail() { return [] }
|
|
1120
|
+
static override relations() { return [PostsManager] }
|
|
1121
|
+
static override async canView(): Promise<boolean> { return false }
|
|
1122
|
+
static override async canEdit(): Promise<boolean> { return false }
|
|
1123
|
+
}
|
|
1124
|
+
const panel = Pilotiq.make('AllGated-' + Math.random()).path('/admin').resources([UserResource, PostResource])
|
|
1125
|
+
|
|
1126
|
+
const out = await resourceEditData(panel, 'users', 'u1')
|
|
1127
|
+
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1128
|
+
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs')
|
|
1129
|
+
// No tabs survive — strip omitted entirely.
|
|
1130
|
+
assert.equal(tabsMeta, undefined)
|
|
1131
|
+
})
|
|
929
1132
|
})
|
|
930
1133
|
|
|
931
1134
|
// ── Plan #11 — dispatchPageData wiring (Vike +data SPA path) ────────
|
|
@@ -1144,3 +1347,249 @@ describe('relation-list TrashedFilter auto-inject (Plan #13 polish)', () => {
|
|
|
1144
1347
|
'user-supplied filter should win over the auto-injected default')
|
|
1145
1348
|
})
|
|
1146
1349
|
})
|
|
1350
|
+
|
|
1351
|
+
// ── Record sub-pages ─────────────────────────────────────
|
|
1352
|
+
|
|
1353
|
+
describe('record sub-pages (pages().record)', () => {
|
|
1354
|
+
class ActivityPage extends Page {
|
|
1355
|
+
static override slug = 'activity'
|
|
1356
|
+
static override label = 'Activity'
|
|
1357
|
+
static override schema() {
|
|
1358
|
+
return [Heading.make('Activity heading')]
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
class ProfilePage extends Page {
|
|
1362
|
+
static override slug = 'profile'
|
|
1363
|
+
static override label = 'Profile'
|
|
1364
|
+
static override schema() {
|
|
1365
|
+
return [Heading.make('Profile heading')]
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// ActivityPage / ProfilePage are module-scope so tests can reference
|
|
1370
|
+
// them inline. `canAccess` is monkey-patched by individual tests via
|
|
1371
|
+
// `buildPanel({ activityCanAccess })`; reset to the default-true
|
|
1372
|
+
// predicate before every test so order of execution stays
|
|
1373
|
+
// independent.
|
|
1374
|
+
beforeEach(() => {
|
|
1375
|
+
;(ActivityPage as unknown as { canAccess: () => Promise<boolean> }).canAccess =
|
|
1376
|
+
async () => true
|
|
1377
|
+
;(ProfilePage as unknown as { canAccess: () => Promise<boolean> }).canAccess =
|
|
1378
|
+
async () => true
|
|
1379
|
+
})
|
|
1380
|
+
|
|
1381
|
+
function buildPanel(opts: {
|
|
1382
|
+
activityCanAccess?: () => boolean | Promise<boolean>
|
|
1383
|
+
userCanView?: () => boolean | Promise<boolean>
|
|
1384
|
+
} = {}) {
|
|
1385
|
+
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
1386
|
+
class UserResource extends Resource {
|
|
1387
|
+
static override slug = 'users'
|
|
1388
|
+
static override recordTitleAttribute = 'name'
|
|
1389
|
+
static override get model() { return ParentModel }
|
|
1390
|
+
static override detail() { return [] }
|
|
1391
|
+
static override pages() {
|
|
1392
|
+
return { record: { activity: ActivityPage, profile: ProfilePage } }
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
if (opts.userCanView) {
|
|
1396
|
+
(UserResource as unknown as { canView: () => unknown }).canView = opts.userCanView
|
|
1397
|
+
}
|
|
1398
|
+
if (opts.activityCanAccess) {
|
|
1399
|
+
(ActivityPage as unknown as { canAccess: () => unknown }).canAccess = opts.activityCanAccess
|
|
1400
|
+
} else {
|
|
1401
|
+
;(ActivityPage as unknown as { canAccess: () => Promise<boolean> }).canAccess =
|
|
1402
|
+
async () => true
|
|
1403
|
+
}
|
|
1404
|
+
const panel = Pilotiq.make('RecPg-' + Math.random()).path('/admin').resources([UserResource])
|
|
1405
|
+
return { panel, UserResource }
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// ── ResourcePages.record widening ──────────────────
|
|
1409
|
+
|
|
1410
|
+
it('Resource.getRecordPages() returns the record map', () => {
|
|
1411
|
+
const { UserResource } = buildPanel()
|
|
1412
|
+
const recordPages = UserResource.getRecordPages()
|
|
1413
|
+
assert.equal(recordPages['activity'], ActivityPage)
|
|
1414
|
+
assert.equal(recordPages['profile'], ProfilePage)
|
|
1415
|
+
})
|
|
1416
|
+
|
|
1417
|
+
it('Resource.getRecordPages() returns {} when no record map is declared', () => {
|
|
1418
|
+
class R extends Resource { static override slug = 'r' }
|
|
1419
|
+
assert.deepEqual(R.getRecordPages(), {})
|
|
1420
|
+
})
|
|
1421
|
+
|
|
1422
|
+
// ── Data builder ──────────────────────────────────
|
|
1423
|
+
|
|
1424
|
+
it('resourceRecordPageData returns null when slug not found', async () => {
|
|
1425
|
+
const { panel } = buildPanel()
|
|
1426
|
+
const out = await resourceRecordPageData(panel, 'nope', 'u1', 'activity')
|
|
1427
|
+
assert.equal(out, null)
|
|
1428
|
+
})
|
|
1429
|
+
|
|
1430
|
+
it('resourceRecordPageData returns null when sub-page slug not registered', async () => {
|
|
1431
|
+
const { panel } = buildPanel()
|
|
1432
|
+
const out = await resourceRecordPageData(panel, 'users', 'u1', 'nope')
|
|
1433
|
+
assert.equal(out, null)
|
|
1434
|
+
})
|
|
1435
|
+
|
|
1436
|
+
it('resourceRecordPageData renders the sub-page schema on success', async () => {
|
|
1437
|
+
const { panel } = buildPanel()
|
|
1438
|
+
const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
|
|
1439
|
+
const data = out as Record<string, unknown>
|
|
1440
|
+
assert.equal(data['pageType'], 'record-page')
|
|
1441
|
+
assert.equal(data['mode'], 'record')
|
|
1442
|
+
assert.equal((data['subPage'] as Record<string, unknown>)['slug'], 'activity')
|
|
1443
|
+
assert.equal((data['subPage'] as Record<string, unknown>)['label'], 'Activity')
|
|
1444
|
+
const schema = data['schemaData'] as Array<Record<string, unknown>>
|
|
1445
|
+
// Activity heading lives inside the page body, prepended by tabs strip.
|
|
1446
|
+
const heading = schema.find(s => s['type'] === 'heading')
|
|
1447
|
+
assert.ok(heading, 'expected the sub-page heading to render')
|
|
1448
|
+
assert.equal(heading!['content'], 'Activity heading')
|
|
1449
|
+
})
|
|
1450
|
+
|
|
1451
|
+
it('resourceRecordPageData 403s when R.canView returns false', async () => {
|
|
1452
|
+
const { panel } = buildPanel({ userCanView: async () => false })
|
|
1453
|
+
const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
|
|
1454
|
+
assert.deepEqual(out, { ok: false, status: 403 })
|
|
1455
|
+
})
|
|
1456
|
+
|
|
1457
|
+
it('resourceRecordPageData 403s when SubPage.canAccess returns false', async () => {
|
|
1458
|
+
const { panel } = buildPanel({ activityCanAccess: async () => false })
|
|
1459
|
+
const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
|
|
1460
|
+
assert.deepEqual(out, { ok: false, status: 403 })
|
|
1461
|
+
})
|
|
1462
|
+
|
|
1463
|
+
it('resourceRecordPageData fails closed when SubPage.canAccess throws', async () => {
|
|
1464
|
+
const { panel } = buildPanel({ activityCanAccess: async () => { throw new Error('boom') } })
|
|
1465
|
+
const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
|
|
1466
|
+
assert.deepEqual(out, { ok: false, status: 403 })
|
|
1467
|
+
})
|
|
1468
|
+
|
|
1469
|
+
// ── RelationTabs insertion ────────────────────────
|
|
1470
|
+
|
|
1471
|
+
it('RelationTabs inserts a tab per sub-page between Edit and managers', async () => {
|
|
1472
|
+
const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
|
|
1473
|
+
Object.assign(ParentModel as object, { relations: { posts: { model: () => stubModel({ rows: [] }) } } })
|
|
1474
|
+
|
|
1475
|
+
class PostsManager extends RelationManager {
|
|
1476
|
+
static override relationship = 'posts'
|
|
1477
|
+
}
|
|
1478
|
+
class UserResource extends Resource {
|
|
1479
|
+
static override slug = 'users'
|
|
1480
|
+
static override get model() { return ParentModel }
|
|
1481
|
+
static override detail() { return [] }
|
|
1482
|
+
static override relations() { return [PostsManager] }
|
|
1483
|
+
static override pages() {
|
|
1484
|
+
return { record: { activity: ActivityPage } }
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
const panel = Pilotiq.make('RecPgTabs-' + Math.random()).path('/admin').resources([UserResource])
|
|
1488
|
+
|
|
1489
|
+
const out = await resourceViewData(panel, 'users', 'u1')
|
|
1490
|
+
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1491
|
+
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
1492
|
+
const tabs = tabsMeta['tabs'] as Array<{ key: string; url: string; active: boolean }>
|
|
1493
|
+
assert.deepEqual(tabs.map(t => t.key), ['__view', '__edit', 'activity', 'posts'])
|
|
1494
|
+
assert.equal(tabs.find(t => t.key === 'activity')?.url, '/admin/users/u1/activity')
|
|
1495
|
+
})
|
|
1496
|
+
|
|
1497
|
+
it('RelationTabs marks the sub-page tab active when rendering through the sub-page', async () => {
|
|
1498
|
+
const { panel } = buildPanel()
|
|
1499
|
+
const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
|
|
1500
|
+
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1501
|
+
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
1502
|
+
const tabs = tabsMeta['tabs'] as Array<{ key: string; active: boolean }>
|
|
1503
|
+
const activity = tabs.find(t => t.key === 'activity')
|
|
1504
|
+
assert.equal(activity?.active, true)
|
|
1505
|
+
})
|
|
1506
|
+
|
|
1507
|
+
it('RelationTabs hides a sub-page tab when its canAccess returns false', async () => {
|
|
1508
|
+
const { panel } = buildPanel({ activityCanAccess: async () => false })
|
|
1509
|
+
// resourceViewData renders __view-active strip; activity sub-page
|
|
1510
|
+
// should drop. profile (default canAccess=true) survives.
|
|
1511
|
+
const out = await resourceViewData(panel, 'users', 'u1')
|
|
1512
|
+
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1513
|
+
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
|
|
1514
|
+
const tabs = tabsMeta['tabs'] as Array<{ key: string }>
|
|
1515
|
+
assert.equal(tabs.find(t => t.key === 'activity'), undefined)
|
|
1516
|
+
assert.ok(tabs.find(t => t.key === 'profile'), 'profile sub-page should remain visible')
|
|
1517
|
+
})
|
|
1518
|
+
|
|
1519
|
+
it('RelationTabs mounts the strip even when only sub-pages exist (no relations)', async () => {
|
|
1520
|
+
// No relation managers — pre-feature, the strip would not mount.
|
|
1521
|
+
// With record sub-pages, the strip mounts to surface them.
|
|
1522
|
+
const { panel } = buildPanel()
|
|
1523
|
+
const out = await resourceViewData(panel, 'users', 'u1')
|
|
1524
|
+
const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
|
|
1525
|
+
const tabsMeta = schema.find(s => s['type'] === 'relation-tabs')
|
|
1526
|
+
assert.ok(tabsMeta, 'strip should mount when sub-pages are registered')
|
|
1527
|
+
})
|
|
1528
|
+
|
|
1529
|
+
// ── dispatchPageData fallthrough ──────────────────
|
|
1530
|
+
|
|
1531
|
+
it('dispatchPageData routes a known sub-page slug through resourceRecordPageData', async () => {
|
|
1532
|
+
PilotiqRegistry.reset()
|
|
1533
|
+
const { panel } = buildPanel()
|
|
1534
|
+
PilotiqRegistry.register(panel)
|
|
1535
|
+
const out = await dispatchPageData({
|
|
1536
|
+
pageId: '/pages/(pilotiq)/relation-list',
|
|
1537
|
+
urlPathname: '/admin/users/u1/activity',
|
|
1538
|
+
routeParams: { basePath: 'admin', slug: 'users', id: 'u1', relationship: 'activity' },
|
|
1539
|
+
urlParsed: { search: {} as Record<string, string> } as never,
|
|
1540
|
+
} as never)
|
|
1541
|
+
const data = out as Record<string, unknown>
|
|
1542
|
+
assert.equal(data['pageType'], 'record-page')
|
|
1543
|
+
})
|
|
1544
|
+
|
|
1545
|
+
it('dispatchPageData still returns null when neither manager nor sub-page matches', async () => {
|
|
1546
|
+
PilotiqRegistry.reset()
|
|
1547
|
+
const { panel } = buildPanel()
|
|
1548
|
+
PilotiqRegistry.register(panel)
|
|
1549
|
+
const out = await dispatchPageData({
|
|
1550
|
+
pageId: '/pages/(pilotiq)/relation-list',
|
|
1551
|
+
urlPathname: '/admin/users/u1/nope',
|
|
1552
|
+
routeParams: { basePath: 'admin', slug: 'users', id: 'u1', relationship: 'nope' },
|
|
1553
|
+
urlParsed: { search: {} as Record<string, string> } as never,
|
|
1554
|
+
} as never)
|
|
1555
|
+
assert.equal(out, null)
|
|
1556
|
+
})
|
|
1557
|
+
|
|
1558
|
+
// ── Boot validation ──────────────────────────────
|
|
1559
|
+
|
|
1560
|
+
it('boot rejects a record sub-page slug colliding with a relation manager', () => {
|
|
1561
|
+
class CollideManager extends RelationManager {
|
|
1562
|
+
static override relationship = 'activity'
|
|
1563
|
+
}
|
|
1564
|
+
class UserResource extends Resource {
|
|
1565
|
+
static override slug = 'users'
|
|
1566
|
+
static override relations() { return [CollideManager] }
|
|
1567
|
+
static override pages() {
|
|
1568
|
+
return { record: { activity: ActivityPage } }
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
// Boot validation runs inside `registerPilotiqRoutes`; emulate by
|
|
1572
|
+
// calling it through the test plumbing if available. For now we
|
|
1573
|
+
// assert the validation by reading the slugs and confirming the
|
|
1574
|
+
// collision is detectable — full route-registration runs in the
|
|
1575
|
+
// integration test below.
|
|
1576
|
+
const managerSlugs = new Set(UserResource.relations().map(M => M.getRelationship()))
|
|
1577
|
+
const recordSlugs = Object.keys(UserResource.getRecordPages())
|
|
1578
|
+
const collisions = recordSlugs.filter(s => managerSlugs.has(s))
|
|
1579
|
+
assert.deepEqual(collisions, ['activity'])
|
|
1580
|
+
})
|
|
1581
|
+
|
|
1582
|
+
it('boot rejects a record sub-page slug with invalid characters', () => {
|
|
1583
|
+
class UserResource extends Resource {
|
|
1584
|
+
static override slug = 'users'
|
|
1585
|
+
static override pages() {
|
|
1586
|
+
return { record: { 'bad slug!': ActivityPage } }
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
const slugs = Object.keys(UserResource.getRecordPages())
|
|
1590
|
+
// Pattern validation lives in `registerPilotiqRoutes`; here we just
|
|
1591
|
+
// assert the recorded slug round-trips so the validator's input is
|
|
1592
|
+
// what the user typed.
|
|
1593
|
+
assert.deepEqual(slugs, ['bad slug!'])
|
|
1594
|
+
})
|
|
1595
|
+
})
|
package/src/routes.ts
CHANGED
|
@@ -19,7 +19,8 @@ import {
|
|
|
19
19
|
panelInfo, callPageSchema, tagFormActions, tagActionDispatch,
|
|
20
20
|
dashboardData, resourceIndexData, resourceTableData,
|
|
21
21
|
resourceCreateData, resourceEditData,
|
|
22
|
-
resourceViewData,
|
|
22
|
+
resourceViewData, resourceRecordPageData,
|
|
23
|
+
globalEditData, globalViewData, customPageData,
|
|
23
24
|
formStateData, type FormStateScope,
|
|
24
25
|
formWizardData,
|
|
25
26
|
formCreateOptionData,
|
|
@@ -707,13 +708,48 @@ export function registerPilotiqRoutes(
|
|
|
707
708
|
if (N.relations().length > 0) {
|
|
708
709
|
throw new Error(
|
|
709
710
|
`[Pilotiq] Nested RelationManager ${N.name} under ${M.name} on ${R.name} declares its own relations(). ` +
|
|
710
|
-
`Phase B caps nesting at depth 2 (
|
|
711
|
+
`Phase B caps nesting at depth 2 (admin-table reference frameworks cap at depth 2 too). Drop the nested relations() override.`,
|
|
711
712
|
)
|
|
712
713
|
}
|
|
713
714
|
}
|
|
714
715
|
}
|
|
715
716
|
}
|
|
716
717
|
|
|
718
|
+
// Record sub-pages — boot-time validation of slugs declared under
|
|
719
|
+
// `Resource.pages().record`. The route URL is
|
|
720
|
+
// `${resourceBase}/${slug}/:id/${subPageSlug}`, so the sub-page slug
|
|
721
|
+
// shares the same URL slot as a relation-manager's relationship. We
|
|
722
|
+
// run the slug-pattern check, the reserved-token check, and the
|
|
723
|
+
// manager-collision check before mounting any routes — a misconfigured
|
|
724
|
+
// sub-page is a dev-time error, not a runtime 404.
|
|
725
|
+
const RECORD_PAGE_SLUG_PATTERN = /^[A-Za-z0-9_-]+$/
|
|
726
|
+
for (const R of cfg.resources) {
|
|
727
|
+
const recordPages = R.getRecordPages()
|
|
728
|
+
const subPageSlugs = Object.keys(recordPages)
|
|
729
|
+
if (subPageSlugs.length === 0) continue
|
|
730
|
+
const managerSlugs = new Set(R.relations().map(M => M.getRelationship()))
|
|
731
|
+
for (const subPageSlug of subPageSlugs) {
|
|
732
|
+
if (!RECORD_PAGE_SLUG_PATTERN.test(subPageSlug)) {
|
|
733
|
+
throw new Error(
|
|
734
|
+
`[Pilotiq] Record sub-page slug ${JSON.stringify(subPageSlug)} on ${R.name} ` +
|
|
735
|
+
`must match /^[A-Za-z0-9_-]+$/. Rename it.`,
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
if (RESERVED_RELATIONSHIP_TOKENS.has(subPageSlug)) {
|
|
739
|
+
throw new Error(
|
|
740
|
+
`[Pilotiq] Record sub-page slug "${subPageSlug}" on ${R.name} collides with a reserved URL token. ` +
|
|
741
|
+
`Reserved tokens: ${[...RESERVED_RELATIONSHIP_TOKENS].join(', ')}. Rename it.`,
|
|
742
|
+
)
|
|
743
|
+
}
|
|
744
|
+
if (managerSlugs.has(subPageSlug)) {
|
|
745
|
+
throw new Error(
|
|
746
|
+
`[Pilotiq] Record sub-page slug "${subPageSlug}" on ${R.name} collides with relation manager relationship "${subPageSlug}". ` +
|
|
747
|
+
`Sub-page slugs and relation slugs share the same URL slot — rename one.`,
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
717
753
|
// Reorderable rows — fail fast at boot when a Resource declares
|
|
718
754
|
// `Table.reorderable()` but the bound model can't actually persist a
|
|
719
755
|
// new order. We invoke `R.table(Table.make())` once per resource (the
|
|
@@ -2952,6 +2988,26 @@ export function registerPilotiqRoutes(
|
|
|
2952
2988
|
}
|
|
2953
2989
|
}
|
|
2954
2990
|
}
|
|
2991
|
+
|
|
2992
|
+
// ── Record sub-pages ───────────────────────────────
|
|
2993
|
+
// `${resourceBase}/:id/${subSlug}` — same URL slot as a relation
|
|
2994
|
+
// manager's `relationship`, distinguished by registry: the data
|
|
2995
|
+
// builder tries the relation lookup first, then falls through to
|
|
2996
|
+
// the record sub-page map. Boot-time validation ensures the slugs
|
|
2997
|
+
// don't collide.
|
|
2998
|
+
for (const [subPageSlug, SubPage] of Object.entries(R.getRecordPages())) {
|
|
2999
|
+
void SubPage // referenced inside `resourceRecordPageData` via the registry; the local binding is captured for closure-stable types only.
|
|
3000
|
+
const recordPageUrl = `${resourceBase}/:id/${subPageSlug}`
|
|
3001
|
+
router.get(recordPageUrl, async (req, res) => {
|
|
3002
|
+
const user = await pilotiq.resolveUser(req)
|
|
3003
|
+
if (!await policyAccess(R, user)) return forbidden(res, wantsJson(req))
|
|
3004
|
+
const recordId = req.params['id']!
|
|
3005
|
+
const data = await resourceRecordPageData(pilotiq, slug, recordId, subPageSlug, req)
|
|
3006
|
+
if (data === null) { res.status(404); return res.send('Not found') }
|
|
3007
|
+
if ('ok' in data && data.ok === false) return forbidden(res, wantsJson(req))
|
|
3008
|
+
return view('pilotiq.slug', data)
|
|
3009
|
+
})
|
|
3010
|
+
}
|
|
2955
3011
|
}
|
|
2956
3012
|
|
|
2957
3013
|
// ── Globals (singletons — 2-segment, no /:id) ────────
|
package/src/schema/Html.ts
CHANGED
|
@@ -53,10 +53,10 @@ export class Html extends Element {
|
|
|
53
53
|
|
|
54
54
|
getType(): string { return 'html' }
|
|
55
55
|
|
|
56
|
-
toMeta() {
|
|
56
|
+
async toMeta() {
|
|
57
57
|
const html = this._sanitize === false
|
|
58
58
|
? this.html
|
|
59
|
-
: sanitizeHtml(this.html, this._sanitize === true ? undefined : this._sanitize)
|
|
59
|
+
: await sanitizeHtml(this.html, this._sanitize === true ? undefined : this._sanitize)
|
|
60
60
|
return {
|
|
61
61
|
type: 'html' as const,
|
|
62
62
|
html,
|
package/src/schema/Markdown.ts
CHANGED
|
@@ -66,7 +66,7 @@ export class Markdown extends Element {
|
|
|
66
66
|
|
|
67
67
|
getType(): string { return 'markdown' }
|
|
68
68
|
|
|
69
|
-
toMeta() {
|
|
69
|
+
async toMeta() {
|
|
70
70
|
const html = marked.parse(this.source, {
|
|
71
71
|
gfm: this._gfm,
|
|
72
72
|
breaks: this._breaks,
|
|
@@ -74,7 +74,7 @@ export class Markdown extends Element {
|
|
|
74
74
|
}) as string
|
|
75
75
|
const finalHtml = this._sanitize === false
|
|
76
76
|
? html
|
|
77
|
-
: sanitizeHtml(html, this._sanitize === true ? undefined : this._sanitize)
|
|
77
|
+
: await sanitizeHtml(html, this._sanitize === true ? undefined : this._sanitize)
|
|
78
78
|
return {
|
|
79
79
|
type: 'markdown' as const,
|
|
80
80
|
html: finalHtml,
|
package/src/schema/Section.ts
CHANGED
|
@@ -25,6 +25,13 @@ export class Section extends Element {
|
|
|
25
25
|
private _persistCollapsed = false
|
|
26
26
|
private _persistKey?: string
|
|
27
27
|
private _afterHeader?: Action[]
|
|
28
|
+
/**
|
|
29
|
+
* Cascading `inlineLabel` default for descendant `Field`s. Read by
|
|
30
|
+
* `resolveSchema.deriveChildContext`. Per-field
|
|
31
|
+
* `Field.inlineLabel(...)` overrides; a more deeply-nested
|
|
32
|
+
* `Section.inlineLabel(...)` overrides for its subtree.
|
|
33
|
+
*/
|
|
34
|
+
private _inlineLabel?: boolean
|
|
28
35
|
|
|
29
36
|
private constructor(private _title?: string) {
|
|
30
37
|
super()
|
|
@@ -65,6 +72,16 @@ export class Section extends Element {
|
|
|
65
72
|
/** Tighter padding + smaller heading. Useful in dense settings pages. */
|
|
66
73
|
compact(v = true): this { this._compact = v; return this }
|
|
67
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Cascade `inlineLabel` to every descendant `Field` in this section
|
|
77
|
+
* whose own `.inlineLabel(...)` hasn't been called. Overrides any
|
|
78
|
+
* outer-form / outer-section setting for this subtree only. Pass
|
|
79
|
+
* `false` to revert to label-above for the subtree.
|
|
80
|
+
*/
|
|
81
|
+
inlineLabel(v = true): this { this._inlineLabel = v; return this }
|
|
82
|
+
/** Internal — read by `resolveSchema.deriveChildContext`. */
|
|
83
|
+
getInlineLabel(): boolean | undefined { return this._inlineLabel }
|
|
84
|
+
|
|
68
85
|
/**
|
|
69
86
|
* Tighter spacing between the section's children. Orthogonal to
|
|
70
87
|
* `compact()` (which trims the section's outer padding/heading):
|