@pilotiq/pilotiq 0.21.0 → 0.23.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 +107 -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/FormStateContext.d.ts +10 -0
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +12 -0
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/PendingSuggestionApplierRegistry.d.ts +12 -0
- package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -1
- package/dist/react/PendingSuggestionApplierRegistry.js +21 -1
- package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -1
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -1
- 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/FormStateContext.tsx +13 -0
- package/src/react/PendingSuggestionApplierRegistry.test.ts +97 -0
- package/src/react/PendingSuggestionApplierRegistry.ts +19 -1
- package/src/react/index.ts +2 -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
|
@@ -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
|
|
@@ -72,6 +72,19 @@ export function useFormState(): FormStateApi | null {
|
|
|
72
72
|
*/
|
|
73
73
|
export const FormIdContext = createContext<string>('')
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Public accessor for the surrounding form's id, normalized to
|
|
77
|
+
* `undefined` when no `FormRenderer` is mounted up-tree (the sentinel
|
|
78
|
+
* empty string maps to `undefined`). Adapter packages — `@pilotiq/tiptap`,
|
|
79
|
+
* `@pilotiq/codemirror` — consume this via the package's `react` re-export
|
|
80
|
+
* to scope per-field registries (pending-suggestion appliers, focus
|
|
81
|
+
* reporters) by form so multi-form pages route correctly to the matching
|
|
82
|
+
* editor instance.
|
|
83
|
+
*/
|
|
84
|
+
export function useFormId(): string | undefined {
|
|
85
|
+
return useContext(FormIdContext) || undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
75
88
|
export interface UseFieldStateResult {
|
|
76
89
|
/** True when the field is inside a controlled form (live fields enabled).
|
|
77
90
|
* Renderers should fall back to their `defaultValue` path when false.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
registerPendingSuggestionApplier,
|
|
6
|
+
getPendingSuggestionApplier,
|
|
7
|
+
_clearAppliersForTests,
|
|
8
|
+
} from './PendingSuggestionApplierRegistry.js'
|
|
9
|
+
|
|
10
|
+
describe('PendingSuggestionApplierRegistry', () => {
|
|
11
|
+
beforeEach(() => { _clearAppliersForTests() })
|
|
12
|
+
|
|
13
|
+
it('returns the scoped applier when both formId and fieldName match', () => {
|
|
14
|
+
const apply = (): void => {}
|
|
15
|
+
registerPendingSuggestionApplier('form-1', 'bio', apply)
|
|
16
|
+
assert.equal(getPendingSuggestionApplier('form-1', 'bio'), apply)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('routes scoped lookups to the matching scoped entry across multi-form pages', () => {
|
|
20
|
+
// Two editors in two forms register under the same field name but
|
|
21
|
+
// different formIds — the routing has to disambiguate.
|
|
22
|
+
const applyA = (): void => {}
|
|
23
|
+
const applyB = (): void => {}
|
|
24
|
+
registerPendingSuggestionApplier('form-A', 'summary', applyA)
|
|
25
|
+
registerPendingSuggestionApplier('form-B', 'summary', applyB)
|
|
26
|
+
assert.equal(getPendingSuggestionApplier('form-A', 'summary'), applyA)
|
|
27
|
+
assert.equal(getPendingSuggestionApplier('form-B', 'summary'), applyB)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('falls through to the wildcard slot when no scoped match exists', () => {
|
|
31
|
+
const wild = (): void => {}
|
|
32
|
+
registerPendingSuggestionApplier(undefined, 'bio', wild)
|
|
33
|
+
// formId provided but the registry only has a wildcard entry —
|
|
34
|
+
// the lookup should still resolve so historic single-form pages keep
|
|
35
|
+
// working even when consumers thread a formId.
|
|
36
|
+
assert.equal(getPendingSuggestionApplier('form-1', 'bio'), wild)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('global producer + scoped consumer: undefined-formId lookup finds the scoped entry', () => {
|
|
40
|
+
// Regression guard for the multi-form fix: after threading `formId`
|
|
41
|
+
// through the Tiptap adapter hooks, every editor registers scoped
|
|
42
|
+
// by its surrounding `FormRenderer`'s id. A global producer (no
|
|
43
|
+
// `formId` stamped on the suggestion) calls
|
|
44
|
+
// `getPendingSuggestionApplier(undefined, fieldName)` — without the
|
|
45
|
+
// fallback, the wildcard slot is empty and the suggestion silently
|
|
46
|
+
// never applies. The fallback returns any matching scoped entry.
|
|
47
|
+
const scopedApply = (): void => {}
|
|
48
|
+
registerPendingSuggestionApplier('form-edit', 'bio', scopedApply)
|
|
49
|
+
assert.equal(getPendingSuggestionApplier(undefined, 'bio'), scopedApply)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('global lookup prefers the explicit wildcard slot over scoped fallback', () => {
|
|
53
|
+
const wild = (): void => {}
|
|
54
|
+
const scoped = (): void => {}
|
|
55
|
+
registerPendingSuggestionApplier('form-edit', 'bio', scoped)
|
|
56
|
+
registerPendingSuggestionApplier(undefined, 'bio', wild)
|
|
57
|
+
// Wildcard wins — it was the intent of the original API ("formId
|
|
58
|
+
// defaults to '*'") and a deliberately-registered wildcard applier
|
|
59
|
+
// is presumed authoritative.
|
|
60
|
+
assert.equal(getPendingSuggestionApplier(undefined, 'bio'), wild)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('scoped lookup prefers exact match over wildcard slot', () => {
|
|
64
|
+
const wild = (): void => {}
|
|
65
|
+
const scoped = (): void => {}
|
|
66
|
+
registerPendingSuggestionApplier(undefined, 'bio', wild)
|
|
67
|
+
registerPendingSuggestionApplier('form-edit', 'bio', scoped)
|
|
68
|
+
assert.equal(getPendingSuggestionApplier('form-edit', 'bio'), scoped)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns undefined when no entry matches the fieldName at all', () => {
|
|
72
|
+
registerPendingSuggestionApplier('form-A', 'bio', () => {})
|
|
73
|
+
assert.equal(getPendingSuggestionApplier('form-A', 'subtitle'), undefined)
|
|
74
|
+
assert.equal(getPendingSuggestionApplier(undefined, 'subtitle'), undefined)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('unregister cleanup drops the entry', () => {
|
|
78
|
+
const apply = (): void => {}
|
|
79
|
+
const unregister = registerPendingSuggestionApplier('form-1', 'bio', apply)
|
|
80
|
+
assert.equal(getPendingSuggestionApplier('form-1', 'bio'), apply)
|
|
81
|
+
unregister()
|
|
82
|
+
assert.equal(getPendingSuggestionApplier('form-1', 'bio'), undefined)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('re-registering replaces the previous entry and its unregister no-ops', () => {
|
|
86
|
+
const first = (): void => {}
|
|
87
|
+
const second = (): void => {}
|
|
88
|
+
const off1 = registerPendingSuggestionApplier('form-1', 'bio', first)
|
|
89
|
+
registerPendingSuggestionApplier('form-1', 'bio', second) // wins
|
|
90
|
+
assert.equal(getPendingSuggestionApplier('form-1', 'bio'), second)
|
|
91
|
+
// First's unregister must NOT delete the second's entry — the
|
|
92
|
+
// registry tracks identity to defend against unmount-after-remount
|
|
93
|
+
// racing the cleanup of the just-replaced entry.
|
|
94
|
+
off1()
|
|
95
|
+
assert.equal(getPendingSuggestionApplier('form-1', 'bio'), second)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -58,6 +58,18 @@ export function registerPendingSuggestionApplier(
|
|
|
58
58
|
* key first; falls back to the wildcard form ('*') so a producer that
|
|
59
59
|
* pushed a suggestion without `formId` still resolves an applier from
|
|
60
60
|
* a single-form page.
|
|
61
|
+
*
|
|
62
|
+
* Global-producer fallback: when the lookup `formId` is `undefined` AND
|
|
63
|
+
* no wildcard entry is registered, return any single scoped entry
|
|
64
|
+
* matching `fieldName`. This mirrors the consumer-side filter in
|
|
65
|
+
* `usePendingSuggestionsForField` which lets undefined formId on either
|
|
66
|
+
* side pass-through. Editors today register scoped by their surrounding
|
|
67
|
+
* `FormRenderer`'s id (`useFormId()`), so the wildcard slot is almost
|
|
68
|
+
* always empty — without this fallback, a producer that pushes without
|
|
69
|
+
* a formId on a single-form page would silently fail to resolve any
|
|
70
|
+
* applier. We pick the first scoped match (Map insertion order); when
|
|
71
|
+
* the page genuinely has multiple forms with the same field name,
|
|
72
|
+
* producers SHOULD stamp `formId` to disambiguate.
|
|
61
73
|
*/
|
|
62
74
|
export function getPendingSuggestionApplier(
|
|
63
75
|
formId: string | undefined,
|
|
@@ -68,7 +80,13 @@ export function getPendingSuggestionApplier(
|
|
|
68
80
|
if (scoped) return scoped.apply
|
|
69
81
|
}
|
|
70
82
|
const wild = _entries.get(keyFor(undefined, fieldName))
|
|
71
|
-
return wild
|
|
83
|
+
if (wild) return wild.apply
|
|
84
|
+
if (formId === undefined) {
|
|
85
|
+
for (const entry of _entries.values()) {
|
|
86
|
+
if (entry.fieldName === fieldName) return entry.apply
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return undefined
|
|
72
90
|
}
|
|
73
91
|
|
|
74
92
|
/**
|
package/src/react/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ export {
|
|
|
39
39
|
type CollabRoom,
|
|
40
40
|
type SyncedProviderLike,
|
|
41
41
|
} from './CollabRoomContext.js'
|
|
42
|
+
export { useCollabSeed } from './useCollabSeed.js'
|
|
42
43
|
export {
|
|
43
44
|
registerCollabExtensions,
|
|
44
45
|
getCollabExtensions,
|
|
@@ -113,6 +114,7 @@ export {
|
|
|
113
114
|
FormStateProvider,
|
|
114
115
|
useFieldState,
|
|
115
116
|
useFormState,
|
|
117
|
+
useFormId,
|
|
116
118
|
useRowBinding,
|
|
117
119
|
type FormStateApi,
|
|
118
120
|
type FormStateProviderProps,
|
|
@@ -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
|
})
|