@pilotiq/pilotiq 0.21.0 → 0.22.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 +2 -2
- package/CHANGELOG.md +88 -0
- package/dist/Pilotiq.d.ts +72 -0
- package/dist/Pilotiq.d.ts.map +1 -1
- package/dist/Pilotiq.js +145 -0
- package/dist/Pilotiq.js.map +1 -1
- package/dist/PilotiqServiceProvider.d.ts +2 -0
- package/dist/PilotiqServiceProvider.d.ts.map +1 -1
- package/dist/PilotiqServiceProvider.js +60 -12
- package/dist/PilotiqServiceProvider.js.map +1 -1
- package/dist/actions/importFactory.d.ts +5 -0
- package/dist/actions/importFactory.d.ts.map +1 -1
- package/dist/actions/importFactory.js +20 -10
- package/dist/actions/importFactory.js.map +1 -1
- package/dist/orm/modelDefaults.d.ts +10 -1
- package/dist/orm/modelDefaults.d.ts.map +1 -1
- package/dist/orm/modelDefaults.js +7 -2
- package/dist/orm/modelDefaults.js.map +1 -1
- package/dist/pageData/forms.js +3 -3
- package/dist/pageData/forms.js.map +1 -1
- package/dist/pageData/misc.js +5 -5
- package/dist/pageData/misc.js.map +1 -1
- package/dist/pageData/navigation.d.ts.map +1 -1
- package/dist/pageData/navigation.js +11 -9
- package/dist/pageData/navigation.js.map +1 -1
- package/dist/pageData/relationPages.d.ts.map +1 -1
- package/dist/pageData/relationPages.js +7 -4
- package/dist/pageData/relationPages.js.map +1 -1
- package/dist/pageData/resourcePages.js +6 -6
- package/dist/pageData/resourcePages.js.map +1 -1
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +1 -0
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/themeEditor.d.ts +20 -1
- package/dist/plugins/themeEditor.d.ts.map +1 -1
- package/dist/plugins/themeEditor.js +3 -1
- package/dist/plugins/themeEditor.js.map +1 -1
- package/dist/react/CollabRoomContext.d.ts +12 -0
- package/dist/react/CollabRoomContext.d.ts.map +1 -1
- package/dist/react/CollabRoomContext.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/useCollabSeed.d.ts +23 -0
- package/dist/react/useCollabSeed.d.ts.map +1 -0
- package/dist/react/useCollabSeed.js +67 -0
- package/dist/react/useCollabSeed.js.map +1 -0
- package/dist/routes/globals.d.ts.map +1 -1
- package/dist/routes/globals.js +8 -22
- package/dist/routes/globals.js.map +1 -1
- package/dist/routes/helpers.d.ts +13 -0
- package/dist/routes/helpers.d.ts.map +1 -1
- package/dist/routes/helpers.js +25 -8
- package/dist/routes/helpers.js.map +1 -1
- package/dist/routes/resources.d.ts.map +1 -1
- package/dist/routes/resources.js +12 -34
- package/dist/routes/resources.js.map +1 -1
- package/dist/routes/theme.d.ts +4 -2
- package/dist/routes/theme.d.ts.map +1 -1
- package/dist/routes/theme.js +27 -26
- package/dist/routes/theme.js.map +1 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +65 -37
- package/dist/routes.js.map +1 -1
- package/dist/theme/index.d.ts +2 -0
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -0
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/storage.d.ts +86 -0
- package/dist/theme/storage.d.ts.map +1 -0
- package/dist/theme/storage.js +52 -0
- package/dist/theme/storage.js.map +1 -0
- package/package.json +1 -1
- package/src/Pilotiq.perf.test.ts +252 -0
- package/src/Pilotiq.test.ts +4 -0
- package/src/Pilotiq.ts +166 -0
- package/src/PilotiqServiceProvider.ts +63 -11
- package/src/actions/importFactory.ts +31 -10
- package/src/orm/modelDefaults.ts +15 -2
- package/src/pageData/forms.ts +3 -3
- package/src/pageData/misc.ts +5 -5
- package/src/pageData/navigation.ts +11 -9
- package/src/pageData/relationPages.ts +5 -3
- package/src/pageData/resourcePages.ts +6 -6
- package/src/plugins/index.ts +7 -0
- package/src/plugins/themeEditor.test.ts +36 -0
- package/src/plugins/themeEditor.ts +22 -1
- package/src/react/CollabRoomContext.ts +12 -0
- package/src/react/index.ts +1 -0
- package/src/react/useCollabSeed.ts +73 -0
- package/src/routes/globals.ts +8 -16
- package/src/routes/guard.test.ts +325 -0
- package/src/routes/helpers.ts +30 -8
- package/src/routes/resources.ts +12 -22
- package/src/routes/theme.ts +26 -44
- package/src/routes.ts +65 -36
- package/src/theme/index.ts +6 -0
- package/src/theme/storage.test.ts +126 -0
- package/src/theme/storage.ts +106 -0
|
@@ -65,6 +65,11 @@ export interface ImportOptions {
|
|
|
65
65
|
* exceeded — protects against accidental million-row uploads. Default
|
|
66
66
|
* `10_000`. */
|
|
67
67
|
maxRows?: number
|
|
68
|
+
/** Number of rows to process in parallel. The importer chunks the row
|
|
69
|
+
* list into batches of this size and runs each chunk via `Promise.all`.
|
|
70
|
+
* Order within a chunk is non-deterministic; row indices in error
|
|
71
|
+
* messages still match the original CSV/JSON position. Default `10`. */
|
|
72
|
+
concurrency?: number
|
|
68
73
|
/** Final hook after the import loop. Useful for audit-log writes,
|
|
69
74
|
* cache invalidation, etc. Async-aware. */
|
|
70
75
|
onComplete?: (summary: ImportSummary, ctx: ImportContext) => void | Promise<void>
|
|
@@ -123,16 +128,21 @@ export async function runImport(
|
|
|
123
128
|
): Promise<ImportSummary> {
|
|
124
129
|
const summary: ImportSummary = { created: 0, updated: 0, skipped: 0, errors: [] }
|
|
125
130
|
const upsertBy = opts.upsertBy
|
|
131
|
+
const concurrency = Math.max(1, opts.concurrency ?? 10)
|
|
126
132
|
|
|
127
|
-
|
|
128
|
-
|
|
133
|
+
// Per-row outcome — a small value type so chunks can resolve in any
|
|
134
|
+
// order and we still accumulate `summary` in original-row order.
|
|
135
|
+
type RowOutcome =
|
|
136
|
+
| { kind: 'created' }
|
|
137
|
+
| { kind: 'updated' }
|
|
138
|
+
| { kind: 'skipped'; row: number; message: string }
|
|
139
|
+
|
|
140
|
+
async function processRow(row: Record<string, unknown>, i: number): Promise<RowOutcome> {
|
|
129
141
|
const rowCtx: ImportContext = { ...ctx, rowIndex: i }
|
|
130
142
|
try {
|
|
131
143
|
const guard = await opts.validate?.(row, rowCtx)
|
|
132
144
|
if (typeof guard === 'string' && guard.length > 0) {
|
|
133
|
-
|
|
134
|
-
summary.errors.push({ row: i + 1, message: guard })
|
|
135
|
-
continue
|
|
145
|
+
return { kind: 'skipped', row: i + 1, message: guard }
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
if (mode === 'upsert' && upsertBy) {
|
|
@@ -144,8 +154,7 @@ export async function runImport(
|
|
|
144
154
|
? await opts.beforeUpdate(row, existing, rowCtx)
|
|
145
155
|
: row
|
|
146
156
|
await M.update(id, payload)
|
|
147
|
-
|
|
148
|
-
continue
|
|
157
|
+
return { kind: 'updated' }
|
|
149
158
|
}
|
|
150
159
|
}
|
|
151
160
|
|
|
@@ -153,10 +162,22 @@ export async function runImport(
|
|
|
153
162
|
? await opts.beforeCreate(row, rowCtx)
|
|
154
163
|
: row
|
|
155
164
|
await M.create(payload)
|
|
156
|
-
|
|
165
|
+
return { kind: 'created' }
|
|
157
166
|
} catch (err) {
|
|
158
|
-
|
|
159
|
-
|
|
167
|
+
return { kind: 'skipped', row: i + 1, message: err instanceof Error ? err.message : 'unknown' }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (let start = 0; start < rows.length; start += concurrency) {
|
|
172
|
+
const slice = rows.slice(start, start + concurrency)
|
|
173
|
+
const outcomes = await Promise.all(slice.map((row, idx) => processRow(row, start + idx)))
|
|
174
|
+
for (const outcome of outcomes) {
|
|
175
|
+
if (outcome.kind === 'created') summary.created++
|
|
176
|
+
else if (outcome.kind === 'updated') summary.updated++
|
|
177
|
+
else {
|
|
178
|
+
summary.skipped++
|
|
179
|
+
summary.errors.push({ row: outcome.row, message: outcome.message })
|
|
180
|
+
}
|
|
160
181
|
}
|
|
161
182
|
}
|
|
162
183
|
|
package/src/orm/modelDefaults.ts
CHANGED
|
@@ -50,6 +50,16 @@ export interface ModelQuery {
|
|
|
50
50
|
orderBy(column: string, direction?: 'ASC' | 'DESC'): ModelQuery
|
|
51
51
|
paginate(page: number, perPage?: number): Promise<{ data: unknown[]; total: number }>
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Single-row `LIMIT 1` SELECT — Laravel-parity sibling of `paginate`
|
|
55
|
+
* for the "first matching row" case. Optional on the structural shape;
|
|
56
|
+
* the `@rudderjs/orm` `QueryBuilder` ships it, test stubs typically
|
|
57
|
+
* don't. Callers that need it should fall back to
|
|
58
|
+
* `(await q.paginate(1, 1)).data[0]` when absent — see `findRecord` /
|
|
59
|
+
* `loadSingularRecord` / `childBelongsToParent` for the pattern.
|
|
60
|
+
*/
|
|
61
|
+
first?(): Promise<unknown | null>
|
|
62
|
+
|
|
53
63
|
/**
|
|
54
64
|
* Plan #13 — soft-delete query scopes. Optional on the structural
|
|
55
65
|
* shape; the `@rudderjs/orm-prisma` QueryBuilder ships them when
|
|
@@ -192,7 +202,7 @@ export function modelLoadRecord(R: ResourceLike): LoadRecordHandler {
|
|
|
192
202
|
* `Global` subclasses with `static model = M` set get this wired
|
|
193
203
|
* automatically by `defaultGlobalEditPage`.
|
|
194
204
|
*
|
|
195
|
-
* Default strategy:
|
|
205
|
+
* Default strategy: `.first()` — i.e. the first matching row. Pass
|
|
196
206
|
* `findSingular` to switch to a fixed-id lookup
|
|
197
207
|
* (`(q) => q.where('id', '=', 1)`) or a slug-style lookup
|
|
198
208
|
* (`(q) => q.where('key', '=', 'site')`).
|
|
@@ -208,6 +218,7 @@ export function loadSingularRecord(
|
|
|
208
218
|
return async (): Promise<unknown> => {
|
|
209
219
|
let q = M.query()
|
|
210
220
|
if (opts?.findSingular) q = opts.findSingular(q)
|
|
221
|
+
if (q.first) return (await q.first()) ?? null
|
|
211
222
|
const result = await q.paginate(1, 1)
|
|
212
223
|
const data = (result?.data ?? []) as unknown[]
|
|
213
224
|
return data[0] ?? null
|
|
@@ -234,7 +245,9 @@ export async function findRecord<T = unknown>(
|
|
|
234
245
|
const M = R.model
|
|
235
246
|
if (!M) return undefined
|
|
236
247
|
const pk = getPrimaryKey(M)
|
|
237
|
-
const
|
|
248
|
+
const q = R.query(ctx).where(pk, '=', id)
|
|
249
|
+
if (q.first) return ((await q.first()) ?? undefined) as T | undefined
|
|
250
|
+
const result = await q.paginate(1, 1)
|
|
238
251
|
const data = (result?.data ?? []) as unknown[]
|
|
239
252
|
return data[0] as T | undefined
|
|
240
253
|
}
|
package/src/pageData/forms.ts
CHANGED
|
@@ -58,7 +58,7 @@ async function resolveScopeForm(
|
|
|
58
58
|
let baseCtxExtras: Record<string, unknown> = {}
|
|
59
59
|
|
|
60
60
|
if (scope.kind === 'resource-create' || scope.kind === 'resource-edit') {
|
|
61
|
-
const R =
|
|
61
|
+
const R = pilotiq.findResource(scope.slug)
|
|
62
62
|
if (!R) return null
|
|
63
63
|
const pages = R.resolvePages()
|
|
64
64
|
if (scope.kind === 'resource-create') {
|
|
@@ -77,14 +77,14 @@ async function resolveScopeForm(
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
} else if (scope.kind === 'global-edit') {
|
|
80
|
-
const G =
|
|
80
|
+
const G = pilotiq.findGlobal(scope.slug)
|
|
81
81
|
if (!G) return null
|
|
82
82
|
const pages = G.resolvePages()
|
|
83
83
|
if (!pages.edit) return null
|
|
84
84
|
PageClass = pages.edit
|
|
85
85
|
mode = 'edit'
|
|
86
86
|
} else {
|
|
87
|
-
const P =
|
|
87
|
+
const P = pilotiq.findPage(scope.pageSlug)
|
|
88
88
|
if (!P) return null
|
|
89
89
|
PageClass = P
|
|
90
90
|
// Custom pages don't have a record/edit-mode concept — pass mode
|
package/src/pageData/misc.ts
CHANGED
|
@@ -43,7 +43,7 @@ export async function globalEditData(
|
|
|
43
43
|
req?: unknown,
|
|
44
44
|
): Promise<Record<string, unknown> | null> {
|
|
45
45
|
const cfg = pilotiq.getConfig()
|
|
46
|
-
const G =
|
|
46
|
+
const G = pilotiq.findGlobal(slug)
|
|
47
47
|
if (!G) return null
|
|
48
48
|
const pages = G.resolvePages()
|
|
49
49
|
if (!pages.edit) return null
|
|
@@ -98,7 +98,7 @@ export async function globalViewData(
|
|
|
98
98
|
req?: unknown,
|
|
99
99
|
): Promise<Record<string, unknown> | null> {
|
|
100
100
|
const cfg = pilotiq.getConfig()
|
|
101
|
-
const G =
|
|
101
|
+
const G = pilotiq.findGlobal(slug)
|
|
102
102
|
if (!G) return null
|
|
103
103
|
const pages = G.resolvePages()
|
|
104
104
|
if (!pages.view) return null
|
|
@@ -134,7 +134,7 @@ export async function customPageData(
|
|
|
134
134
|
req?: unknown,
|
|
135
135
|
): Promise<Record<string, unknown> | null> {
|
|
136
136
|
const cfg = pilotiq.getConfig()
|
|
137
|
-
const PageClass =
|
|
137
|
+
const PageClass = pilotiq.findPage(pageSlug)
|
|
138
138
|
if (!PageClass) return null
|
|
139
139
|
|
|
140
140
|
const pageUrl = pageBasePath(cfg.path, PageClass)
|
|
@@ -237,14 +237,14 @@ export async function widgetData(
|
|
|
237
237
|
ctx = uploadCtx(userCtx({ basePath: cfg.path }, user), cfg)
|
|
238
238
|
elements = await callPageSchema(cfg.dashboardPage, ctx)
|
|
239
239
|
} else if (scope.kind === 'page') {
|
|
240
|
-
const P =
|
|
240
|
+
const P = pilotiq.findPage(scope.pageSlug)
|
|
241
241
|
if (!P) return { ok: false, status: 404, error: 'Page not found' }
|
|
242
242
|
ctx = uploadCtx(userCtx({ basePath: cfg.path }, user), cfg)
|
|
243
243
|
elements = await callPageSchema(P, ctx)
|
|
244
244
|
} else {
|
|
245
245
|
// Resource-scope: re-resolve the list page's schema so widgets from
|
|
246
246
|
// `Resource.headerSchema()` / `footerSchema()` are reachable.
|
|
247
|
-
const R =
|
|
247
|
+
const R = pilotiq.findResource(scope.slug)
|
|
248
248
|
if (!R) return { ok: false, status: 404, error: 'Resource not found' }
|
|
249
249
|
const pages = R.resolvePages()
|
|
250
250
|
if (!pages.index) return { ok: false, status: 404, error: 'Resource has no list page' }
|
|
@@ -627,7 +627,7 @@ export async function buildNavigation(pilotiq: Pilotiq, user: unknown): Promise<
|
|
|
627
627
|
const raw: RawNavItem[] = []
|
|
628
628
|
let idx = 0
|
|
629
629
|
|
|
630
|
-
const pushBadge: Array<{ item: RawNavItem; handler: () => unknown }> = []
|
|
630
|
+
const pushBadge: Array<{ item: RawNavItem; handler: () => unknown; owner: string }> = []
|
|
631
631
|
|
|
632
632
|
// Plan #10 — pre-evaluate canAccess for every owner in parallel so we
|
|
633
633
|
// can drop forbidden items before flattening. Failed predicates fail
|
|
@@ -672,7 +672,7 @@ export async function buildNavigation(pilotiq: Pilotiq, user: unknown): Promise<
|
|
|
672
672
|
if (R.cluster) item.parent = R.cluster.name
|
|
673
673
|
else if (R.navigationParentItem !== undefined) item.parent = R.navigationParentItem
|
|
674
674
|
if (R.navigationBadgeColor !== 'default') item.badgeColor = R.navigationBadgeColor
|
|
675
|
-
if (R.navigationBadge) pushBadge.push({ item, handler: R.navigationBadge })
|
|
675
|
+
if (R.navigationBadge) pushBadge.push({ item, handler: R.navigationBadge, owner: R.name })
|
|
676
676
|
raw.push(item)
|
|
677
677
|
}
|
|
678
678
|
|
|
@@ -697,7 +697,7 @@ export async function buildNavigation(pilotiq: Pilotiq, user: unknown): Promise<
|
|
|
697
697
|
if (G.cluster) item.parent = G.cluster.name
|
|
698
698
|
else if (G.navigationParentItem !== undefined) item.parent = G.navigationParentItem
|
|
699
699
|
if (G.navigationBadgeColor !== 'default') item.badgeColor = G.navigationBadgeColor
|
|
700
|
-
if (G.navigationBadge) pushBadge.push({ item, handler: G.navigationBadge })
|
|
700
|
+
if (G.navigationBadge) pushBadge.push({ item, handler: G.navigationBadge, owner: G.name })
|
|
701
701
|
raw.push(item)
|
|
702
702
|
}
|
|
703
703
|
|
|
@@ -724,7 +724,7 @@ export async function buildNavigation(pilotiq: Pilotiq, user: unknown): Promise<
|
|
|
724
724
|
if (P.cluster && !isDashboard) item.parent = P.cluster.name
|
|
725
725
|
else if (P.navigationParentItem !== undefined) item.parent = P.navigationParentItem
|
|
726
726
|
if (P.navigationBadgeColor !== 'default') item.badgeColor = P.navigationBadgeColor
|
|
727
|
-
if (P.navigationBadge) pushBadge.push({ item, handler: P.navigationBadge })
|
|
727
|
+
if (P.navigationBadge) pushBadge.push({ item, handler: P.navigationBadge, owner: P.name })
|
|
728
728
|
raw.push(item)
|
|
729
729
|
}
|
|
730
730
|
|
|
@@ -755,15 +755,17 @@ export async function buildNavigation(pilotiq: Pilotiq, user: unknown): Promise<
|
|
|
755
755
|
if (C.navigationSort !== undefined) item.sort = C.navigationSort
|
|
756
756
|
if (C.navigationParentItem !== undefined) item.parent = C.navigationParentItem
|
|
757
757
|
if (C.navigationBadgeColor !== 'default') item.badgeColor = C.navigationBadgeColor
|
|
758
|
-
if (C.navigationBadge) pushBadge.push({ item, handler: C.navigationBadge })
|
|
758
|
+
if (C.navigationBadge) pushBadge.push({ item, handler: C.navigationBadge, owner: C.name })
|
|
759
759
|
raw.push(item)
|
|
760
760
|
}
|
|
761
761
|
|
|
762
|
-
await Promise.all(pushBadge.map(async ({ item, handler }) => {
|
|
762
|
+
await Promise.all(pushBadge.map(async ({ item, handler, owner }) => {
|
|
763
763
|
try {
|
|
764
|
-
const v = await
|
|
765
|
-
|
|
766
|
-
|
|
764
|
+
const v = await pilotiq.resolveNavigationBadge(owner, user, async () => {
|
|
765
|
+
const raw = await handler()
|
|
766
|
+
return raw === undefined || raw === null ? undefined : String(raw)
|
|
767
|
+
})
|
|
768
|
+
if (v !== undefined) item.badge = v
|
|
767
769
|
} catch {
|
|
768
770
|
// Per-badge errors stay silent.
|
|
769
771
|
}
|
|
@@ -193,7 +193,9 @@ async function childBelongsToParent(
|
|
|
193
193
|
const q: ModelQuery = (parentModel.relatedQuery
|
|
194
194
|
? parentModel.relatedQuery(parent, relationship)
|
|
195
195
|
: (parent as { related: (n: string) => ModelQuery }).related(relationship))
|
|
196
|
-
|
|
196
|
+
.where(childPk, '=', childId)
|
|
197
|
+
if (q.first) return (await q.first()) !== null
|
|
198
|
+
const result = await q.paginate(1, 1)
|
|
197
199
|
return result.total > 0
|
|
198
200
|
} catch {
|
|
199
201
|
return false
|
|
@@ -294,7 +296,7 @@ export async function relationManagerData(
|
|
|
294
296
|
|
|
295
297
|
const cfg = pilotiq.getConfig()
|
|
296
298
|
|
|
297
|
-
const R =
|
|
299
|
+
const R = pilotiq.findResource(scope.slug)
|
|
298
300
|
if (!R) return null
|
|
299
301
|
|
|
300
302
|
const M = findManager(R, scope.relationship)
|
|
@@ -769,7 +771,7 @@ export async function resolveRelationChain(
|
|
|
769
771
|
): Promise<ResolvedChain | { ok: false; status: 403 } | null> {
|
|
770
772
|
const cfg = pilotiq.getConfig()
|
|
771
773
|
|
|
772
|
-
const R =
|
|
774
|
+
const R = pilotiq.findResource(scope.slug)
|
|
773
775
|
if (!R) return null
|
|
774
776
|
|
|
775
777
|
// Layer 0 — same gates as the depth-1 pipeline.
|
|
@@ -142,7 +142,7 @@ export async function resourceIndexData(
|
|
|
142
142
|
req?: unknown,
|
|
143
143
|
): Promise<Record<string, unknown> | null> {
|
|
144
144
|
const cfg = pilotiq.getConfig()
|
|
145
|
-
const R =
|
|
145
|
+
const R = pilotiq.findResource(slug)
|
|
146
146
|
if (!R) return null
|
|
147
147
|
|
|
148
148
|
const pages = R.resolvePages()
|
|
@@ -193,7 +193,7 @@ export async function resourceTableData(
|
|
|
193
193
|
req?: unknown,
|
|
194
194
|
): Promise<{ tables: Record<string, unknown>[] } | null> {
|
|
195
195
|
const cfg = pilotiq.getConfig()
|
|
196
|
-
const R =
|
|
196
|
+
const R = pilotiq.findResource(slug)
|
|
197
197
|
if (!R) return null
|
|
198
198
|
|
|
199
199
|
const pages = R.resolvePages()
|
|
@@ -321,7 +321,7 @@ export async function resourceCreateData(
|
|
|
321
321
|
req?: unknown,
|
|
322
322
|
): Promise<Record<string, unknown> | null> {
|
|
323
323
|
const cfg = pilotiq.getConfig()
|
|
324
|
-
const R =
|
|
324
|
+
const R = pilotiq.findResource(slug)
|
|
325
325
|
if (!R) return null
|
|
326
326
|
const pages = R.resolvePages()
|
|
327
327
|
if (!pages.create) return null
|
|
@@ -373,7 +373,7 @@ export async function resourceEditData(
|
|
|
373
373
|
req?: unknown,
|
|
374
374
|
): Promise<Record<string, unknown> | null> {
|
|
375
375
|
const cfg = pilotiq.getConfig()
|
|
376
|
-
const R =
|
|
376
|
+
const R = pilotiq.findResource(slug)
|
|
377
377
|
if (!R) return null
|
|
378
378
|
const pages = R.resolvePages()
|
|
379
379
|
if (!pages.edit) return null
|
|
@@ -466,7 +466,7 @@ export async function resourceViewData(
|
|
|
466
466
|
req?: unknown,
|
|
467
467
|
): Promise<Record<string, unknown> | null> {
|
|
468
468
|
const cfg = pilotiq.getConfig()
|
|
469
|
-
const R =
|
|
469
|
+
const R = pilotiq.findResource(slug)
|
|
470
470
|
if (!R) return null
|
|
471
471
|
const pages = R.resolvePages()
|
|
472
472
|
if (!pages.view) return null
|
|
@@ -538,7 +538,7 @@ export async function resourceRecordPageData(
|
|
|
538
538
|
req?: unknown,
|
|
539
539
|
): Promise<Record<string, unknown> | null | { ok: false; status: 403 }> {
|
|
540
540
|
const cfg = pilotiq.getConfig()
|
|
541
|
-
const R =
|
|
541
|
+
const R = pilotiq.findResource(slug)
|
|
542
542
|
if (!R) return null
|
|
543
543
|
const recordPages = R.getRecordPages()
|
|
544
544
|
const PageClass = recordPages[subPageSlug]
|
package/src/plugins/index.ts
CHANGED
|
@@ -1 +1,8 @@
|
|
|
1
1
|
export { themeEditor } from './themeEditor.js'
|
|
2
|
+
export type { ThemeEditorOptions } from './themeEditor.js'
|
|
3
|
+
export { prismaThemeStorage } from '../theme/storage.js'
|
|
4
|
+
export type {
|
|
5
|
+
ThemeStorageAdapter,
|
|
6
|
+
PanelGlobalDelegate,
|
|
7
|
+
PrismaThemeStorageOptions,
|
|
8
|
+
} from '../theme/storage.js'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { Pilotiq } from '../Pilotiq.js'
|
|
4
|
+
import { themeEditor } from './themeEditor.js'
|
|
5
|
+
import type { ThemeStorageAdapter } from '../theme/storage.js'
|
|
6
|
+
|
|
7
|
+
function makeStubAdapter(): ThemeStorageAdapter {
|
|
8
|
+
return {
|
|
9
|
+
async load() { return null },
|
|
10
|
+
async save() { /* noop */ },
|
|
11
|
+
async clear() { /* noop */ },
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('themeEditor() plugin', () => {
|
|
16
|
+
it('bare themeEditor() leaves the storage slot undefined (boot resolves it)', () => {
|
|
17
|
+
const panel = Pilotiq.make('admin').use(themeEditor())
|
|
18
|
+
assert.equal(panel.getConfig().themeEditor, true)
|
|
19
|
+
assert.equal(panel.getThemeStorage(), undefined)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('themeEditor({ storage }) stamps the adapter onto the panel', () => {
|
|
23
|
+
const adapter = makeStubAdapter()
|
|
24
|
+
const panel = Pilotiq.make('admin').use(themeEditor({ storage: adapter }))
|
|
25
|
+
assert.equal(panel.getThemeStorage(), adapter)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('themeEditor() can be called on multiple panels without leaking adapters', () => {
|
|
29
|
+
const a = makeStubAdapter()
|
|
30
|
+
const b = makeStubAdapter()
|
|
31
|
+
const panelA = Pilotiq.make('admin').use(themeEditor({ storage: a }))
|
|
32
|
+
const panelB = Pilotiq.make('billing').use(themeEditor({ storage: b }))
|
|
33
|
+
assert.equal(panelA.getThemeStorage(), a)
|
|
34
|
+
assert.equal(panelB.getThemeStorage(), b)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import type { PilotiqPlugin } from '../Pilotiq.js'
|
|
2
|
+
import type { ThemeStorageAdapter } from '../theme/storage.js'
|
|
3
|
+
|
|
4
|
+
export interface ThemeEditorOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Override persistence adapter. Defaults to the implicit Prisma
|
|
7
|
+
* fallback (auto-resolved from `app.make('prisma')` against the
|
|
8
|
+
* `panelGlobal` row) — that fallback is deprecated; pass an explicit
|
|
9
|
+
* adapter to opt out and silence the deprecation warning.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { prismaThemeStorage, themeEditor } from '@pilotiq/pilotiq/plugins'
|
|
14
|
+
*
|
|
15
|
+
* .use(themeEditor({
|
|
16
|
+
* storage: prismaThemeStorage(prisma, { slug: 'admin__theme' }),
|
|
17
|
+
* }))
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
storage?: ThemeStorageAdapter
|
|
21
|
+
}
|
|
2
22
|
|
|
3
23
|
/**
|
|
4
24
|
* Theme editor plugin — adds an interactive theme customization page
|
|
@@ -14,11 +34,12 @@ import type { PilotiqPlugin } from '../Pilotiq.js'
|
|
|
14
34
|
* .use(themeEditor())
|
|
15
35
|
* ```
|
|
16
36
|
*/
|
|
17
|
-
export function themeEditor(): PilotiqPlugin {
|
|
37
|
+
export function themeEditor(opts: ThemeEditorOptions = {}): PilotiqPlugin {
|
|
18
38
|
return {
|
|
19
39
|
name: 'theme-editor',
|
|
20
40
|
register(panel) {
|
|
21
41
|
panel.enableThemeEditor()
|
|
42
|
+
if (opts.storage) panel._setThemeStorage(opts.storage)
|
|
22
43
|
},
|
|
23
44
|
}
|
|
24
45
|
}
|
|
@@ -22,6 +22,18 @@ export interface CollabRoom {
|
|
|
22
22
|
ydoc: unknown
|
|
23
23
|
/** `WebsocketProvider` instance. Opaque to pilotiq core. */
|
|
24
24
|
provider: unknown
|
|
25
|
+
/**
|
|
26
|
+
* Resolves on the provider's first sync. Present when the room is
|
|
27
|
+
* wired through `@rudderjs/sync/react`'s `CollabRoomManager` (which is
|
|
28
|
+
* what `@pilotiq-pro/collab@>=0.2`'s `<RecordCollabRoom>` does);
|
|
29
|
+
* absent for legacy / hand-rolled providers. `useCollabSeed` gates
|
|
30
|
+
* its seed callback on this Promise — adapters that need a
|
|
31
|
+
* "fragment-empty?" check after first sync should consume the hook
|
|
32
|
+
* rather than calling `onProviderSynced` themselves.
|
|
33
|
+
*/
|
|
34
|
+
synced?: Promise<void>
|
|
35
|
+
/** IndexedDB persistence handle, when the room wraps `y-indexeddb`. Opaque. */
|
|
36
|
+
persistence?: unknown
|
|
25
37
|
/** Presence info for cursors / avatars. Forwarded to the extension factory. */
|
|
26
38
|
user?: {
|
|
27
39
|
name?: string
|
package/src/react/index.ts
CHANGED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { CollabRoom } from './CollabRoomContext.js'
|
|
3
|
+
|
|
4
|
+
const SEED_ORIGIN = 'pilotiq-collab-seed'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run `seedFn` exactly once after the collab room's first sync.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors `@rudderjs/sync/react`'s `useCollabSeed` shape — same purpose,
|
|
10
|
+
* reimplemented here so pilotiq core stays free of any hard runtime dep
|
|
11
|
+
* on Yjs. The `room` parameter is pilotiq's opaque `CollabRoom`, so the
|
|
12
|
+
* seed callback receives `doc: unknown` and is responsible for its own
|
|
13
|
+
* share-type lookup (`doc.getXmlFragment(key)` for Tiptap consumers,
|
|
14
|
+
* `doc.getText(key)` for CodeMirror, etc.) and its own emptiness check.
|
|
15
|
+
*
|
|
16
|
+
* Returns `true` once the seed callback has run (or was skipped because
|
|
17
|
+
* the room has no `.synced` Promise — i.e. a legacy non-framework
|
|
18
|
+
* provider), so consumers can gate editor mount / placeholder swap.
|
|
19
|
+
*
|
|
20
|
+
* The hook wraps `seedFn` in `doc.transact(..., 'pilotiq-collab-seed')`
|
|
21
|
+
* so downstream observers can filter the synthetic write. Two peers
|
|
22
|
+
* mounting against a brand-new record may both see "empty" and both
|
|
23
|
+
* seed — same race window as the legacy `onProviderSynced` path; the
|
|
24
|
+
* fix is server-side seed handoff, deferred.
|
|
25
|
+
*/
|
|
26
|
+
export function useCollabSeed(
|
|
27
|
+
room: CollabRoom | null,
|
|
28
|
+
key: string,
|
|
29
|
+
seedFn: (doc: unknown) => void,
|
|
30
|
+
): boolean {
|
|
31
|
+
const [seeded, setSeeded] = useState(false)
|
|
32
|
+
const seedFnRef = useRef(seedFn)
|
|
33
|
+
seedFnRef.current = seedFn
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!room) {
|
|
37
|
+
setSeeded(false)
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
// Legacy rooms without `.synced` — the room owner is on the hook
|
|
41
|
+
// for whatever first-sync gating they used to do via
|
|
42
|
+
// `onProviderSynced`. Mark seeded immediately so the consumer
|
|
43
|
+
// can mount without a placeholder. No seedFn runs.
|
|
44
|
+
const syncedPromise = room.synced
|
|
45
|
+
if (!syncedPromise) {
|
|
46
|
+
setSeeded(true)
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let cancelled = false
|
|
51
|
+
syncedPromise.then(() => {
|
|
52
|
+
if (cancelled) return
|
|
53
|
+
try {
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
55
|
+
const ydoc = room.ydoc as any
|
|
56
|
+
if (ydoc && typeof ydoc.transact === 'function') {
|
|
57
|
+
ydoc.transact(() => seedFnRef.current(ydoc), SEED_ORIGIN)
|
|
58
|
+
} else {
|
|
59
|
+
seedFnRef.current(ydoc)
|
|
60
|
+
}
|
|
61
|
+
} catch { /* ignore — seed is best-effort */ }
|
|
62
|
+
setSeeded(true)
|
|
63
|
+
}).catch(() => {
|
|
64
|
+
// synced rejects when the room is torn down before first sync.
|
|
65
|
+
// Don't surface as a seed failure — the room is gone.
|
|
66
|
+
if (!cancelled) setSeeded(false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
return () => { cancelled = true }
|
|
70
|
+
}, [room, key])
|
|
71
|
+
|
|
72
|
+
return seeded
|
|
73
|
+
}
|
package/src/routes/globals.ts
CHANGED
|
@@ -15,8 +15,7 @@ import {
|
|
|
15
15
|
normalizeRedirect,
|
|
16
16
|
splitMeta,
|
|
17
17
|
forbidden,
|
|
18
|
-
|
|
19
|
-
policyAccess,
|
|
18
|
+
policyGate,
|
|
20
19
|
handleFormState,
|
|
21
20
|
handleFormWizard,
|
|
22
21
|
handleFormCreateOption,
|
|
@@ -49,8 +48,7 @@ export function registerGlobalRoutes(
|
|
|
49
48
|
// POST ${editUrl}/_form/:formId/state
|
|
50
49
|
router.post(`${editUrl}/_form/:formId/state`, async (req, res) => {
|
|
51
50
|
const user = await pilotiq.resolveUser(req)
|
|
52
|
-
if (!await
|
|
53
|
-
if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
|
|
51
|
+
if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, true)
|
|
54
52
|
const formId = req.params['formId']!
|
|
55
53
|
return handleFormState(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
|
|
56
54
|
})
|
|
@@ -58,8 +56,7 @@ export function registerGlobalRoutes(
|
|
|
58
56
|
// Plan #8 wizard step-validate endpoint for the global's edit form.
|
|
59
57
|
router.post(`${editUrl}/_form/:formId/wizard`, async (req, res) => {
|
|
60
58
|
const user = await pilotiq.resolveUser(req)
|
|
61
|
-
if (!await
|
|
62
|
-
if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
|
|
59
|
+
if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, true)
|
|
63
60
|
const formId = req.params['formId']!
|
|
64
61
|
return handleFormWizard(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
|
|
65
62
|
})
|
|
@@ -67,8 +64,7 @@ export function registerGlobalRoutes(
|
|
|
67
64
|
// Async-mention endpoint for the global's edit form.
|
|
68
65
|
router.post(`${editUrl}/_form/:formId/mentions`, async (req, res) => {
|
|
69
66
|
const user = await pilotiq.resolveUser(req)
|
|
70
|
-
if (!await
|
|
71
|
-
if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
|
|
67
|
+
if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, true)
|
|
72
68
|
const formId = req.params['formId']!
|
|
73
69
|
return handleFormMentions(req, res, pilotiq, { kind: 'global-edit', slug }, formId)
|
|
74
70
|
})
|
|
@@ -76,8 +72,7 @@ export function registerGlobalRoutes(
|
|
|
76
72
|
// SelectField inline-create modal endpoint for the global's edit form.
|
|
77
73
|
router.post(`${editUrl}/_form/:formId/create-option/:fieldName`, async (req, res) => {
|
|
78
74
|
const user = await pilotiq.resolveUser(req)
|
|
79
|
-
if (!await
|
|
80
|
-
if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, true)
|
|
75
|
+
if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, true)
|
|
81
76
|
const formId = req.params['formId']!
|
|
82
77
|
const fieldName = req.params['fieldName']!
|
|
83
78
|
return handleFormCreateOption(req, res, pilotiq, { kind: 'global-edit', slug }, formId, fieldName)
|
|
@@ -85,11 +80,10 @@ export function registerGlobalRoutes(
|
|
|
85
80
|
|
|
86
81
|
router.get(editUrl, async (req, res) => {
|
|
87
82
|
const user = await pilotiq.resolveUser(req)
|
|
88
|
-
if (!await policyAccess(G, user)) return forbidden(res, wantsJson(req))
|
|
89
83
|
// Globals carry their record on the singleton form's `loadRecord`;
|
|
90
84
|
// we don't pre-load here — pass a stub so canEdit's signature is
|
|
91
85
|
// honored, and let user code decide whether to consult it.
|
|
92
|
-
if (!await
|
|
86
|
+
if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, wantsJson(req))
|
|
93
87
|
const data = await globalEditData(pilotiq, slug, undefined, req)
|
|
94
88
|
return view('pilotiq.slug', data ?? {})
|
|
95
89
|
})
|
|
@@ -100,8 +94,7 @@ export function registerGlobalRoutes(
|
|
|
100
94
|
const json = wantsJson(req)
|
|
101
95
|
|
|
102
96
|
const user = await pilotiq.resolveUser(req)
|
|
103
|
-
if (!await
|
|
104
|
-
if (!await checkPolicy(() => G.canEdit(user, undefined))) return forbidden(res, json)
|
|
97
|
+
if (!await policyGate(G, user, () => G.canEdit(user, undefined))) return forbidden(res, json)
|
|
105
98
|
|
|
106
99
|
const ctx: SchemaContext = { mode: 'edit', basePath: base, ...(user !== null ? { user: user as NonNullable<SchemaContext['user']> } : {}) }
|
|
107
100
|
const elements = await callPageSchema(PageClass, ctx)
|
|
@@ -147,8 +140,7 @@ export function registerGlobalRoutes(
|
|
|
147
140
|
if (pages.view) {
|
|
148
141
|
router.get(`${editUrl}/view`, async (req, res) => {
|
|
149
142
|
const user = await pilotiq.resolveUser(req)
|
|
150
|
-
if (!await
|
|
151
|
-
if (!await checkPolicy(() => G.canView(user, undefined))) return forbidden(res, wantsJson(req))
|
|
143
|
+
if (!await policyGate(G, user, () => G.canView(user, undefined))) return forbidden(res, wantsJson(req))
|
|
152
144
|
const data = await globalViewData(pilotiq, slug, req)
|
|
153
145
|
return view('pilotiq.resource-view', data ?? {})
|
|
154
146
|
})
|