@pilotiq/pilotiq 0.20.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 +104 -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/CollabTextRendererRegistry.d.ts +19 -1
- package/dist/react/CollabTextRendererRegistry.d.ts.map +1 -1
- package/dist/react/CollabTextRendererRegistry.js.map +1 -1
- package/dist/react/fields/MarkdownInput.js +1 -1
- package/dist/react/fields/MarkdownInput.js.map +1 -1
- package/dist/react/fields/TextLikeInput.js +1 -1
- package/dist/react/fields/TextLikeInput.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/CollabTextRendererRegistry.ts +19 -1
- package/src/react/fields/MarkdownInput.tsx +2 -1
- package/src/react/fields/TextLikeInput.tsx +2 -1
- 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
|
@@ -3,9 +3,14 @@ import type { Application } from '@rudderjs/core'
|
|
|
3
3
|
import type { Pilotiq } from './Pilotiq.js'
|
|
4
4
|
import { PilotiqRegistry } from './PilotiqRegistry.js'
|
|
5
5
|
import { registerPilotiqRoutes } from './routes.js'
|
|
6
|
+
import { migrateThemeOverrides } from './theme/migrate.js'
|
|
7
|
+
import { prismaThemeStorage } from './theme/storage.js'
|
|
8
|
+
import type { PanelGlobalDelegate, ThemeStorageAdapter } from './theme/storage.js'
|
|
6
9
|
|
|
7
10
|
// ─── Service Provider ─────────────────────────────────────
|
|
8
11
|
|
|
12
|
+
const autoFallbackWarned = new Set<string>()
|
|
13
|
+
|
|
9
14
|
class PilotiqServiceProvider extends ServiceProvider {
|
|
10
15
|
private panels: Pilotiq[]
|
|
11
16
|
|
|
@@ -26,25 +31,72 @@ class PilotiqServiceProvider extends ServiceProvider {
|
|
|
26
31
|
router: Parameters<typeof registerPilotiqRoutes>[0]
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
// Load saved theme overrides from DB for panels with themeEditor enabled
|
|
30
34
|
for (const panel of PilotiqRegistry.all()) {
|
|
31
35
|
if (panel.getConfig().themeEditor) {
|
|
32
|
-
|
|
33
|
-
const prisma = this.app.make('prisma') as any
|
|
34
|
-
const slug = `${panel.getConfig().name}__theme`
|
|
35
|
-
const row = await prisma.panelGlobal.findUnique({ where: { slug } })
|
|
36
|
-
if (row?.data) {
|
|
37
|
-
const raw = typeof row.data === 'string' ? JSON.parse(row.data as string) : row.data
|
|
38
|
-
const { migrateThemeOverrides } = await import('./theme/migrate.js')
|
|
39
|
-
panel.setThemeOverrides(migrateThemeOverrides(raw))
|
|
40
|
-
}
|
|
41
|
-
} catch { /* no DB or no table — use code defaults */ }
|
|
36
|
+
await loadThemeOverrides(this.app, panel)
|
|
42
37
|
}
|
|
43
38
|
registerPilotiqRoutes(router, panel)
|
|
44
39
|
}
|
|
45
40
|
}
|
|
46
41
|
}
|
|
47
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the panel's theme storage adapter and hydrate any persisted
|
|
45
|
+
* overrides onto the panel.
|
|
46
|
+
*
|
|
47
|
+
* - Explicit `themeEditor({ storage })`: errors bubble (the user opted
|
|
48
|
+
* in, misconfiguration should surface loudly).
|
|
49
|
+
* - Implicit Prisma fallback: errors swallowed for back-compat with a
|
|
50
|
+
* one-time deprecation warning. Removing this branch is the breaking
|
|
51
|
+
* change scheduled for the next minor.
|
|
52
|
+
*/
|
|
53
|
+
async function loadThemeOverrides(app: Application, panel: Pilotiq): Promise<void> {
|
|
54
|
+
const adapter = resolveThemeStorage(app, panel)
|
|
55
|
+
if (!adapter) return
|
|
56
|
+
|
|
57
|
+
const isExplicit = panel.getConfig().themeStorage === adapter
|
|
58
|
+
if (!isExplicit) panel._setThemeStorage(adapter)
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const overrides = await adapter.load()
|
|
62
|
+
if (overrides) panel.setThemeOverrides(migrateThemeOverrides(overrides))
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (isExplicit) throw e
|
|
65
|
+
// Implicit fallback: swallow connection / schema errors. Removed
|
|
66
|
+
// alongside the auto-fallback branch in a future minor.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveThemeStorage(app: Application, panel: Pilotiq): ThemeStorageAdapter | null {
|
|
71
|
+
const explicit = panel.getConfig().themeStorage
|
|
72
|
+
if (explicit) return explicit
|
|
73
|
+
|
|
74
|
+
let prisma: PanelGlobalDelegate | null
|
|
75
|
+
try {
|
|
76
|
+
prisma = app.make('prisma') as PanelGlobalDelegate
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
if (!prisma || typeof prisma.panelGlobal?.findUnique !== 'function') return null
|
|
81
|
+
|
|
82
|
+
const panelName = panel.getConfig().name
|
|
83
|
+
if (!autoFallbackWarned.has(panelName)) {
|
|
84
|
+
autoFallbackWarned.add(panelName)
|
|
85
|
+
console.warn(
|
|
86
|
+
`[pilotiq] themeEditor() on panel "${panelName}" is using the implicit ` +
|
|
87
|
+
`Prisma fallback for theme persistence. Pass storage explicitly — ` +
|
|
88
|
+
`themeEditor({ storage: prismaThemeStorage(prisma, { slug: '${panelName}__theme' }) }) — ` +
|
|
89
|
+
`the implicit fallback is deprecated and will be removed in a future minor.`,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
return prismaThemeStorage(prisma, { slug: `${panelName}__theme` })
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @internal — test seam; resets the "deprecation already warned" memo. */
|
|
96
|
+
export function _resetThemeFallbackWarned(): void {
|
|
97
|
+
autoFallbackWarned.clear()
|
|
98
|
+
}
|
|
99
|
+
|
|
48
100
|
// ─── Factory ──────────────────────────────────────────────
|
|
49
101
|
|
|
50
102
|
/**
|
|
@@ -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
|
|
@@ -24,8 +24,26 @@ import type { ComponentType } from 'react'
|
|
|
24
24
|
* keeps the seam narrow: handler callbacks + DOM chrome only.
|
|
25
25
|
*/
|
|
26
26
|
export interface CollabTextRendererProps {
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Field name — drives the FormData hidden input AND the routing key
|
|
29
|
+
* for AI suggestion delivery (chip widget, applier registry). For
|
|
30
|
+
* Repeater / Builder row leaves this is the dotted positional path
|
|
31
|
+
* (`items.0.title`).
|
|
32
|
+
*/
|
|
28
33
|
name: string
|
|
34
|
+
/**
|
|
35
|
+
* Collab-stable identifier for the `Y.XmlFragment` selector. When
|
|
36
|
+
* present, the renderer binds its collab fragment under this key
|
|
37
|
+
* instead of `name`. Row leaves pass a row-id-anchored composite
|
|
38
|
+
* (`items.<rowId>.title`) so the fragment survives reorders even
|
|
39
|
+
* as the dotted positional `name` shifts. Top-level fields omit
|
|
40
|
+
* it — `name` is stable on its own.
|
|
41
|
+
*
|
|
42
|
+
* AI suggestion routing continues to use `name` regardless. Tool
|
|
43
|
+
* calls reference fields by their positional FormData name, not
|
|
44
|
+
* the collab-stable composite.
|
|
45
|
+
*/
|
|
46
|
+
fragmentKey?: string
|
|
29
47
|
/** `true` for textarea-like (multiple paragraphs); `false` for input-like. */
|
|
30
48
|
multiline: boolean
|
|
31
49
|
/**
|
|
@@ -463,7 +463,8 @@ function MarkdownCollabInput({
|
|
|
463
463
|
<div style={wrapperStyle} className="overflow-auto">
|
|
464
464
|
<input type="hidden" name={hiddenInputName} value={text} />
|
|
465
465
|
<Renderer
|
|
466
|
-
name={
|
|
466
|
+
name={hiddenInputName}
|
|
467
|
+
{...(fragmentKey !== hiddenInputName ? { fragmentKey } : {})}
|
|
467
468
|
multiline={true}
|
|
468
469
|
defaultValue={initial}
|
|
469
470
|
{...(placeholder !== undefined ? { placeholder } : {})}
|
|
@@ -233,7 +233,8 @@ function CollabTextField({
|
|
|
233
233
|
<>
|
|
234
234
|
<input type="hidden" name={hiddenInputName} value={text} />
|
|
235
235
|
<Renderer
|
|
236
|
-
name={
|
|
236
|
+
name={hiddenInputName}
|
|
237
|
+
{...(fragmentKey !== hiddenInputName ? { fragmentKey } : {})}
|
|
237
238
|
multiline={multiline}
|
|
238
239
|
defaultValue={defaultValue}
|
|
239
240
|
{...(placeholder !== undefined ? { placeholder } : {})}
|