@pilotiq/pilotiq 0.11.0 → 0.13.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 +21 -0
- package/dist/react/AppShell.d.ts +1 -1
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js +7 -1
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/CollabTextRendererRegistry.d.ts +75 -0
- package/dist/react/CollabTextRendererRegistry.d.ts.map +1 -0
- package/dist/react/CollabTextRendererRegistry.js +18 -0
- package/dist/react/CollabTextRendererRegistry.js.map +1 -0
- package/dist/react/CurrentUserContext.d.ts +39 -0
- package/dist/react/CurrentUserContext.d.ts.map +1 -0
- package/dist/react/CurrentUserContext.js +27 -0
- package/dist/react/CurrentUserContext.js.map +1 -0
- package/dist/react/FormCollabBindingRegistry.d.ts +17 -84
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
- package/dist/react/FormCollabBindingRegistry.js.map +1 -1
- package/dist/react/FormStateContext.d.ts +1 -35
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +7 -91
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/RowCoordsContext.d.ts +19 -0
- package/dist/react/RowCoordsContext.d.ts.map +1 -0
- package/dist/react/RowCoordsContext.js +6 -0
- package/dist/react/RowCoordsContext.js.map +1 -0
- package/dist/react/fields/BuilderInput.d.ts.map +1 -1
- package/dist/react/fields/BuilderInput.js +14 -9
- package/dist/react/fields/BuilderInput.js.map +1 -1
- package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
- package/dist/react/fields/MarkdownInput.js +70 -101
- package/dist/react/fields/MarkdownInput.js.map +1 -1
- package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
- package/dist/react/fields/RepeaterInput.js +26 -17
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/fields/TextLikeInput.d.ts +11 -9
- package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
- package/dist/react/fields/TextLikeInput.js +111 -164
- package/dist/react/fields/TextLikeInput.js.map +1 -1
- package/dist/react/formStateHelpers.d.ts +0 -15
- package/dist/react/formStateHelpers.d.ts.map +1 -1
- package/dist/react/formStateHelpers.js +0 -91
- package/dist/react/formStateHelpers.js.map +1 -1
- package/dist/react/index.d.ts +3 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +2 -0
- package/dist/react/index.js.map +1 -1
- package/package.json +5 -5
- package/src/react/AppShell.tsx +11 -1
- package/src/react/CollabTextRendererRegistry.ts +84 -0
- package/src/react/CurrentUserContext.tsx +50 -0
- package/src/react/FormCollabBindingRegistry.ts +17 -77
- package/src/react/FormStateContext.tsx +6 -125
- package/src/react/RowCoordsContext.tsx +23 -0
- package/src/react/fields/BuilderInput.tsx +22 -10
- package/src/react/fields/MarkdownInput.tsx +125 -95
- package/src/react/fields/RepeaterInput.tsx +41 -16
- package/src/react/fields/TextLikeInput.tsx +147 -181
- package/src/react/formStateHelpers.test.ts +0 -99
- package/src/react/formStateHelpers.ts +0 -83
- package/src/react/index.ts +12 -2
- package/dist/react/fields/textDelta.d.ts +0 -44
- package/dist/react/fields/textDelta.d.ts.map +0 -1
- package/dist/react/fields/textDelta.js +0 -80
- package/dist/react/fields/textDelta.js.map +0 -1
- package/src/react/fields/textDelta.test.ts +0 -141
- package/src/react/fields/textDelta.ts +0 -86
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pilotiq/pilotiq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "View-based admin panel for RudderJS",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -81,10 +81,10 @@
|
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
83
|
"@base-ui/react": "^1",
|
|
84
|
-
"@rudderjs/contracts": "^1.
|
|
85
|
-
"@rudderjs/core": "^1.1.
|
|
86
|
-
"@rudderjs/router": "^1.
|
|
87
|
-
"@rudderjs/view": "^1.0
|
|
84
|
+
"@rudderjs/contracts": "^1.6.0",
|
|
85
|
+
"@rudderjs/core": "^1.1.4",
|
|
86
|
+
"@rudderjs/router": "^1.2.0",
|
|
87
|
+
"@rudderjs/view": "^1.1.0",
|
|
88
88
|
"@types/node": "^20",
|
|
89
89
|
"@types/react": "^19",
|
|
90
90
|
"@types/sanitize-html": "^2.16.1",
|
package/src/react/AppShell.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import type { NavItem, UserMenuMeta, DatabaseNotificationsMeta, RightSidebarMeta
|
|
|
16
16
|
import type { RenderHookMap } from '../RenderHook.js'
|
|
17
17
|
import { RenderHookSlot } from './RenderHookSlot.js'
|
|
18
18
|
import type { ComponentSlotRegistry } from './component-slots.js'
|
|
19
|
+
import { CurrentUserProvider } from './CurrentUserContext.js'
|
|
19
20
|
|
|
20
21
|
export interface AppShellProps {
|
|
21
22
|
panel: {
|
|
@@ -180,7 +181,16 @@ export function AppShell({ layout = 'sidebar', notifications, componentRegistry,
|
|
|
180
181
|
props.basePath,
|
|
181
182
|
)
|
|
182
183
|
|
|
183
|
-
|
|
184
|
+
// `CurrentUserProvider` sits OUTSIDE the layout-provider chain so
|
|
185
|
+
// plugin-registered providers (e.g. `@pilotiq-pro/collab`'s
|
|
186
|
+
// CollabProvider, which threads the user into CollaborationCaret
|
|
187
|
+
// presence labels) can read the active user via `useCurrentUser()`.
|
|
188
|
+
// Value source mirrors what the top-right user dropdown renders.
|
|
189
|
+
return (
|
|
190
|
+
<CurrentUserProvider value={props.panel.userMenu?.user ?? null}>
|
|
191
|
+
{wrapped}
|
|
192
|
+
</CurrentUserProvider>
|
|
193
|
+
)
|
|
184
194
|
}
|
|
185
195
|
|
|
186
196
|
/**
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Module-level registry slot for the collab-aware plain-text editor renderer.
|
|
5
|
+
*
|
|
6
|
+
* Wiring posture (mirrors `CollabExtensionFactoryRegistry` /
|
|
7
|
+
* `FormCollabBindingRegistry`):
|
|
8
|
+
* - `@pilotiq/tiptap`'s `registerTiptap(...)` calls `registerCollabTextRenderer(...)`
|
|
9
|
+
* once at boot. The registered component closes over `@tiptap/*` imports so
|
|
10
|
+
* pilotiq core stays free of any tiptap peer dep — same posture as the
|
|
11
|
+
* existing rich-text renderer registry.
|
|
12
|
+
* - `TextLikeInput` checks for the registered component when a `<RecordCollabRoom>`
|
|
13
|
+
* is mounted up-tree AND the field hasn't opted out via `.collab(false)` AND
|
|
14
|
+
* the field has no `.mask()`. If present, the legacy `BoundTextInput`
|
|
15
|
+
* (Y.Text + `computeDelta` + heuristic `preserveCursor`) is bypassed in
|
|
16
|
+
* favour of a y-prosemirror-backed Tiptap editor that anchors selections to
|
|
17
|
+
* `Yjs.RelativePosition` — the architectural fix for the cursor-jump and
|
|
18
|
+
* two-peer concurrent-insert races documented in
|
|
19
|
+
* `docs/plans/text-fields-tiptap-backed-collab.md`.
|
|
20
|
+
*
|
|
21
|
+
* Wire props are deliberately framework-agnostic — the renderer doesn't take a
|
|
22
|
+
* `binding` since it consumes the room's `ydoc` directly via the existing
|
|
23
|
+
* `useCollabRoom()` + `getCollabExtensions()` plumbing on its own side. Core
|
|
24
|
+
* keeps the seam narrow: handler callbacks + DOM chrome only.
|
|
25
|
+
*/
|
|
26
|
+
export interface CollabTextRendererProps {
|
|
27
|
+
/** Field name — drives the `Y.XmlFragment` selector inside the collab adapter. */
|
|
28
|
+
name: string
|
|
29
|
+
/** `true` for textarea-like (multiple paragraphs); `false` for input-like. */
|
|
30
|
+
multiline: boolean
|
|
31
|
+
/**
|
|
32
|
+
* Server-rendered default value. The renderer is expected to seed the
|
|
33
|
+
* `Y.XmlFragment` from this on first connect when the room has no
|
|
34
|
+
* persisted state for this field (i.e. brand-new record).
|
|
35
|
+
*/
|
|
36
|
+
defaultValue: string
|
|
37
|
+
/** Optional placeholder hint. */
|
|
38
|
+
placeholder?: string
|
|
39
|
+
/** Disabled / read-only state. */
|
|
40
|
+
disabled?: boolean
|
|
41
|
+
/** Fired on every editor `update` with the editor's current plain text. */
|
|
42
|
+
onChange: (text: string) => void
|
|
43
|
+
/** Fired on editor blur — host wires this to live-onBlur trigger semantics. */
|
|
44
|
+
onBlur: () => void
|
|
45
|
+
/**
|
|
46
|
+
* Single-line submit — fired when `multiline: false` AND the user presses
|
|
47
|
+
* Enter. The renderer is expected to blur the editor after invoking this.
|
|
48
|
+
* Multiline mode ignores it (Enter inserts a paragraph instead).
|
|
49
|
+
*/
|
|
50
|
+
onSubmit?: () => void
|
|
51
|
+
/**
|
|
52
|
+
* Tailwind className applied to the editor's contenteditable wrapper so the
|
|
53
|
+
* rendered editor matches the native `<input>` / `<textarea>` chrome it
|
|
54
|
+
* replaces. The host owns the styling — the adapter just forwards.
|
|
55
|
+
*/
|
|
56
|
+
className?: string
|
|
57
|
+
/**
|
|
58
|
+
* Additional DOM attributes for the editor's contenteditable wrapper —
|
|
59
|
+
* typically `id`, `aria-*`, `autocomplete`, etc.
|
|
60
|
+
*/
|
|
61
|
+
editorAttributes?: Record<string, string>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type CollabTextRenderer = ComponentType<CollabTextRendererProps>
|
|
65
|
+
|
|
66
|
+
let _renderer: CollabTextRenderer | null = null
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Register the collab plain-text editor component. Called once at boot by
|
|
70
|
+
* `@pilotiq/tiptap`'s `registerTiptap()` (or directly by an app that imports
|
|
71
|
+
* the renderer). Calling with `null` clears the registry — useful for tests.
|
|
72
|
+
*
|
|
73
|
+
* No-op behaviour when no renderer is registered: `TextLikeInput` falls back
|
|
74
|
+
* to the legacy `BoundTextInput` path (or the plain controlled / uncontrolled
|
|
75
|
+
* input when no collab room is mounted).
|
|
76
|
+
*/
|
|
77
|
+
export function registerCollabTextRenderer(component: CollabTextRenderer | null): void {
|
|
78
|
+
_renderer = component
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Returns the registered component, or `null` when no adapter is installed. */
|
|
82
|
+
export function getCollabTextRenderer(): CollabTextRenderer | null {
|
|
83
|
+
return _renderer
|
|
84
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolved identity of the user driving the current page. Mirrors the
|
|
5
|
+
* `UserMenuMeta.user` shape that `panelInfo()` ships to the renderer —
|
|
6
|
+
* whichever fields the `Pilotiq.user(req => …)` resolver populated.
|
|
7
|
+
*
|
|
8
|
+
* `null` is the no-user state: either the panel never wired a resolver,
|
|
9
|
+
* or the resolver returned `null` for this request. Consumers should
|
|
10
|
+
* gracefully fall back (no avatar, no presence label, etc.) rather than
|
|
11
|
+
* treating absence as an error.
|
|
12
|
+
*/
|
|
13
|
+
export interface CurrentUser {
|
|
14
|
+
name?: string
|
|
15
|
+
email?: string
|
|
16
|
+
avatar?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CurrentUserContext = createContext<CurrentUser | null>(null)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mounted by `AppShell` around the layout-provider chain so plugins
|
|
23
|
+
* (collab user presence, audit-trail attribution, analytics
|
|
24
|
+
* client-side opt-outs, …) can read the active user via
|
|
25
|
+
* `useCurrentUser()` without prop-drilling through `panel`.
|
|
26
|
+
*
|
|
27
|
+
* Value source is `viewProps.panel.userMenu?.user` — the same shape the
|
|
28
|
+
* top-right dropdown renders. The provider sits OUTSIDE
|
|
29
|
+
* `layoutProviderRegistry` so plugin-registered layout providers can
|
|
30
|
+
* subscribe.
|
|
31
|
+
*/
|
|
32
|
+
export function CurrentUserProvider({
|
|
33
|
+
value,
|
|
34
|
+
children,
|
|
35
|
+
}: {
|
|
36
|
+
value: CurrentUser | null
|
|
37
|
+
children: ReactNode
|
|
38
|
+
}): ReactNode {
|
|
39
|
+
return <CurrentUserContext.Provider value={value}>{children}</CurrentUserContext.Provider>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Read the active user inside any descendant of `<AppShell>`. Returns
|
|
44
|
+
* `null` outside an `AppShell` mount (defensive — keeps storybook /
|
|
45
|
+
* isolated-render tests from throwing) and when no user resolved for
|
|
46
|
+
* the request.
|
|
47
|
+
*/
|
|
48
|
+
export function useCurrentUser(): CurrentUser | null {
|
|
49
|
+
return useContext(CurrentUserContext)
|
|
50
|
+
}
|
|
@@ -1,44 +1,6 @@
|
|
|
1
1
|
import type { ElementMeta } from '../schema/Element.js'
|
|
2
2
|
import type { CollabRoom } from './CollabRoomContext.js'
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Phase F.6 — character-level edit op emitted by `TextLikeInput` and
|
|
6
|
-
* applied through `TextBinding.applyDelta`. `replace` covers IME / paste
|
|
7
|
-
* / multi-char selections; `insert` and `delete` cover the single-key
|
|
8
|
-
* common path. Pilotiq core stays Yjs-free — the binding impl in
|
|
9
|
-
* `@pilotiq-pro/collab` translates these into `Y.Text.insert / delete`
|
|
10
|
-
* inside a transaction.
|
|
11
|
-
*/
|
|
12
|
-
export type TextDelta =
|
|
13
|
-
| { kind: 'insert', index: number, text: string }
|
|
14
|
-
| { kind: 'delete', index: number, length: number }
|
|
15
|
-
| { kind: 'replace', from: number, to: number, text: string }
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Phase F.6 — per-field character-level CRDT handle. Issued by
|
|
19
|
-
* `FormCollabBinding.getTextBinding(name)` for text-shaped fields
|
|
20
|
-
* (`TextField / TextareaField / EmailField / SlugField / MarkdownField`);
|
|
21
|
-
* returns `null` for non-text fields or text fields opted out via
|
|
22
|
-
* `.collab(false)`. The surface stays intentionally narrow so pilotiq
|
|
23
|
-
* core never touches Yjs directly — same posture as `FormCollabBinding`.
|
|
24
|
-
*
|
|
25
|
-
* - `read()` returns the current full string. `TextLikeInput` calls
|
|
26
|
-
* this once on mount to seed its controlled value.
|
|
27
|
-
* - `applyDelta(delta)` is called from `onInput` events with a single
|
|
28
|
-
* `insert / delete / replace` op derived from the input's selection.
|
|
29
|
-
* - `observe(fn)` registers a remote-change listener; `fn(next)`
|
|
30
|
-
* receives the post-change string. Returns an unsubscribe function.
|
|
31
|
-
* - `destroy()` cleans up everything the handle holds. The owning
|
|
32
|
-
* `FormCollabBinding.destroy()` is expected to cascade — consumers
|
|
33
|
-
* don't need to call this directly.
|
|
34
|
-
*/
|
|
35
|
-
export interface TextBinding {
|
|
36
|
-
read(): string
|
|
37
|
-
applyDelta(delta: TextDelta): void
|
|
38
|
-
observe(fn: (next: string) => void): () => void
|
|
39
|
-
destroy(): void
|
|
40
|
-
}
|
|
41
|
-
|
|
42
4
|
/**
|
|
43
5
|
* Binding contract that a collab plugin returns from
|
|
44
6
|
* `registerFormCollabBinding` — wraps a single form's value map in a
|
|
@@ -55,14 +17,14 @@ export interface TextBinding {
|
|
|
55
17
|
* - `subscribe(fn)` registers a listener that fires when REMOTE
|
|
56
18
|
* changes land; `fn(snapshot)` receives the full updated map.
|
|
57
19
|
* The provider re-applies this snapshot onto its React state.
|
|
58
|
-
* - `getTextBinding(name)` (Phase F.6) returns a `Y.Text`-backed
|
|
59
|
-
* handle for text-shaped fields, or `null` for non-text fields and
|
|
60
|
-
* text fields opted out via `.collab(false)`. The text/non-text
|
|
61
|
-
* allowlist lives in the binding impl — `FormStateProvider` asks
|
|
62
|
-
* for every field and routes per-field on the answer.
|
|
63
20
|
* - `destroy()` is called on unmount — gives the plugin a chance to
|
|
64
|
-
* remove its CRDT observer.
|
|
65
|
-
*
|
|
21
|
+
* remove its CRDT observer.
|
|
22
|
+
*
|
|
23
|
+
* Text-field character-level CRDT lives in `@pilotiq/tiptap`'s
|
|
24
|
+
* `CollabTextRenderer` (`Y.XmlFragment` per field, keyed by bare name
|
|
25
|
+
* for top-level + `${arrayName}.${rowId}.${fieldName}` composite for
|
|
26
|
+
* row leaves) — pilotiq core no longer mediates per-field text CRDT,
|
|
27
|
+
* so this binding only handles non-text values + row lifecycle.
|
|
66
28
|
*
|
|
67
29
|
* `unknown` payloads keep pilotiq core Yjs-free; the binding owns its
|
|
68
30
|
* own type knowledge. Same posture as `CollabExtensionFactory`.
|
|
@@ -74,12 +36,6 @@ export interface FormCollabBinding {
|
|
|
74
36
|
set(name: string, value: unknown): void
|
|
75
37
|
/** Subscribe to remote changes. Returns an unsubscribe function. */
|
|
76
38
|
subscribe(fn: (snapshot: Record<string, unknown>) => void): () => void
|
|
77
|
-
/** Phase F.6 — per-field text-CRDT handle. Returns `null` for non-text
|
|
78
|
-
* fields or text fields opted out via `.collab(false)`. Optional so
|
|
79
|
-
* existing F1-era plugins keep type-checking without a no-op stub;
|
|
80
|
-
* when absent, every text field stays on today's whole-string LWW
|
|
81
|
-
* path (i.e. F.6 character-level CRDT is opt-in by impl). */
|
|
82
|
-
getTextBinding?(name: string): TextBinding | null
|
|
83
39
|
/** Cleanup hook called when the form unmounts. */
|
|
84
40
|
destroy(): void
|
|
85
41
|
|
|
@@ -125,11 +81,10 @@ export interface FormCollabBinding {
|
|
|
125
81
|
/**
|
|
126
82
|
* Write a single field on a row. Replaces the dotted-path `set` for
|
|
127
83
|
* row leaves (`tags.0.label` → `setRow('tags', rowId, 'label', value)`).
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* scalar field-map under LWW.
|
|
84
|
+
* Non-text row fields land on the row's scalar field-map under LWW;
|
|
85
|
+
* text row fields don't flow through here — the Tiptap renderer
|
|
86
|
+
* writes them directly to a `Y.XmlFragment` at the doc root keyed by
|
|
87
|
+
* `${arrayName}.${rowId}.${fieldName}`.
|
|
133
88
|
*
|
|
134
89
|
* `FormStateProvider` calls this on every local edit when both a
|
|
135
90
|
* dotted-path name is being written AND `setRow` is implemented;
|
|
@@ -137,19 +92,6 @@ export interface FormCollabBinding {
|
|
|
137
92
|
*/
|
|
138
93
|
setRow?(arrayName: string, rowId: string, fieldName: string, value: unknown): void
|
|
139
94
|
|
|
140
|
-
/**
|
|
141
|
-
* Per-row text-CRDT handle. Composes with F.6's `BoundTextInput`:
|
|
142
|
-
* the renderer asks for one of these when a row leaf is text-shaped
|
|
143
|
-
* AND not opted out via `.collab(false)`, then threads it through
|
|
144
|
-
* the existing `TextBinding` plumbing inside the row.
|
|
145
|
-
*
|
|
146
|
-
* Returns `null` for non-text fields, fields opted out via collab,
|
|
147
|
-
* rows not yet present in the binding's index (e.g. before
|
|
148
|
-
* `addRow` has propagated), or when the binding doesn't yet
|
|
149
|
-
* implement per-row text CRDT (deferred to F.5c).
|
|
150
|
-
*/
|
|
151
|
-
getRowTextBinding?(arrayName: string, rowId: string, fieldName: string): TextBinding | null
|
|
152
|
-
|
|
153
95
|
/**
|
|
154
96
|
* Subscribe to row-lifecycle events for a Repeater/Builder array.
|
|
155
97
|
* Fires on remote add / remove / move; the renderer reconciles its
|
|
@@ -232,14 +174,12 @@ export interface FormCollabBindingFactoryArgs {
|
|
|
232
174
|
*/
|
|
233
175
|
initial: Record<string, unknown>
|
|
234
176
|
/**
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
* (
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
* structural changes from `live()` re-resolves aren't re-walked
|
|
242
|
-
* (rare in practice — dynamic field add/remove is an F-followup).
|
|
177
|
+
* Initial form meta from the server. The binding walks this once at
|
|
178
|
+
* construction to index Repeater / Builder array names for row-level
|
|
179
|
+
* CRDT. Text fields (top-level and row leaves alike) don't need to be
|
|
180
|
+
* partitioned here — the Tiptap renderer owns their `Y.XmlFragment`s
|
|
181
|
+
* at the doc root. The meta is captured at mount; later structural
|
|
182
|
+
* changes from `live()` re-resolves aren't re-walked.
|
|
243
183
|
*/
|
|
244
184
|
formMeta: ElementMeta
|
|
245
185
|
}
|
|
@@ -11,13 +11,10 @@ import type { ElementMeta } from '../schema/Element.js'
|
|
|
11
11
|
import {
|
|
12
12
|
collectFieldDefaults,
|
|
13
13
|
collectRowArrayFieldNames,
|
|
14
|
-
collectRowTextLeavesByArray,
|
|
15
14
|
findFieldMeta,
|
|
16
15
|
parseFormDataToNested,
|
|
17
|
-
parseRowFieldPath,
|
|
18
16
|
readNestedValue,
|
|
19
17
|
routeBindingWrite,
|
|
20
|
-
rowIdAtIndex,
|
|
21
18
|
writeNestedValue,
|
|
22
19
|
} from './formStateHelpers.js'
|
|
23
20
|
import { runJsHandler } from './fieldJsHandler.js'
|
|
@@ -27,7 +24,6 @@ import {
|
|
|
27
24
|
getFormCollabBinding,
|
|
28
25
|
type FormCollabBinding,
|
|
29
26
|
type RowBindingApi,
|
|
30
|
-
type TextBinding,
|
|
31
27
|
} from './FormCollabBindingRegistry.js'
|
|
32
28
|
|
|
33
29
|
export type FieldStatus = 'idle' | 'pending'
|
|
@@ -50,36 +46,11 @@ export interface FormStateApi {
|
|
|
50
46
|
formMeta: ElementMeta
|
|
51
47
|
inFlight: boolean
|
|
52
48
|
fieldStatus: (name: string) => FieldStatus
|
|
53
|
-
/** Phase F.6 — per-field text-CRDT handles stashed at collab-room mount.
|
|
54
|
-
* `null` outside a room or before the binding effect has populated the
|
|
55
|
-
* map. The text/non-text allowlist lives in the binding impl —
|
|
56
|
-
* `FormStateProvider` asks for every top-level field and only stashes
|
|
57
|
-
* non-null answers, so a `Map.get()` hit means the binding has opted
|
|
58
|
-
* this field into the character-level path. */
|
|
59
|
-
textBindings: ReadonlyMap<string, TextBinding> | null
|
|
60
49
|
/** Phase F.5 — per-Repeater/Builder row-array bindings. `null` outside a
|
|
61
50
|
* collab room or when the binding doesn't implement F.5 row methods.
|
|
62
51
|
* Each entry's API methods are pre-bound to the array name so renderers
|
|
63
52
|
* call `.add(rowId, initial)` rather than `binding.addRow(name, …)`. */
|
|
64
53
|
rowBindings: ReadonlyMap<string, RowBindingApi> | null
|
|
65
|
-
/**
|
|
66
|
-
* Phase F.5c — per-array set of inner-field names that should route
|
|
67
|
-
* through `Y.Text` (character-level CRDT) instead of row-level Y.Map
|
|
68
|
-
* LWW. Built from a single meta walk at binding mount. Read by
|
|
69
|
-
* `useFieldState(dottedName).textBinding` to decide whether to call
|
|
70
|
-
* `getRowTextBinding`. Sparse — only arrays with at least one
|
|
71
|
-
* text-shaped row leaf appear; absence on a key means "no text leaves
|
|
72
|
-
* in this Repeater/Builder".
|
|
73
|
-
*/
|
|
74
|
-
rowTextLeaves: ReadonlyMap<string, ReadonlySet<string>> | null
|
|
75
|
-
/**
|
|
76
|
-
* Phase F.5c — resolve a per-row `TextBinding`. Pre-bound to the
|
|
77
|
-
* active F.5 binding so consumers don't reach for `bindingRef`
|
|
78
|
-
* directly. Returns `null` when no binding implements F.5c OR the
|
|
79
|
-
* row+field doesn't qualify (renderer caller should fall back to
|
|
80
|
-
* `defaultValue` like a non-collab form).
|
|
81
|
-
*/
|
|
82
|
-
getRowTextBinding: ((arrayName: string, rowId: string, fieldName: string) => TextBinding | null) | null
|
|
83
54
|
}
|
|
84
55
|
|
|
85
56
|
const FormStateContext = createContext<FormStateApi | null>(null)
|
|
@@ -120,15 +91,6 @@ export interface UseFieldStateResult {
|
|
|
120
91
|
/** True while a live re-resolve POST is in flight for this field. */
|
|
121
92
|
pending: boolean
|
|
122
93
|
errors: string[]
|
|
123
|
-
/** Phase F.6 — character-level CRDT handle for text-shaped fields when
|
|
124
|
-
* a collab room is mounted up-tree AND the binding strategy applies
|
|
125
|
-
* (allowlist + `.collab() !== false`). Null in every other case —
|
|
126
|
-
* outside a `FormStateProvider`, outside a `<RecordCollabRoom>`, on
|
|
127
|
-
* non-text fields, on dotted-path inner-Repeater rows (deferred to
|
|
128
|
-
* F.5), and on text fields opted out via `.collab(false)`. Text input
|
|
129
|
-
* renderers branch on this: non-null → character-level path with
|
|
130
|
-
* `applyDelta + observe`; null → today's whole-string LWW path. */
|
|
131
|
-
textBinding: TextBinding | null
|
|
132
94
|
}
|
|
133
95
|
|
|
134
96
|
/**
|
|
@@ -164,7 +126,6 @@ export function useFieldState(name: string): UseFieldStateResult {
|
|
|
164
126
|
triggerLive: () => {},
|
|
165
127
|
pending: false,
|
|
166
128
|
errors: [],
|
|
167
|
-
textBinding: null,
|
|
168
129
|
}
|
|
169
130
|
}
|
|
170
131
|
// Dotted-path fields (inner Repeater rows) always render uncontrolled
|
|
@@ -177,35 +138,9 @@ export function useFieldState(name: string): UseFieldStateResult {
|
|
|
177
138
|
triggerLive: (valueOverride?: unknown) => ctx.triggerLive(name, valueOverride),
|
|
178
139
|
pending: ctx.fieldStatus(name) === 'pending',
|
|
179
140
|
errors: ctx.errors[name] ?? [],
|
|
180
|
-
// Phase F.6 — top-level text fields resolve from the binding-mount
|
|
181
|
-
// text stash. Phase F.5c — dotted-path row leaves resolve through
|
|
182
|
-
// `getRowTextBinding` when the field is text-shaped AND the row's
|
|
183
|
-
// `__id` is already stamped in the values map. Outside a collab
|
|
184
|
-
// room, for non-text fields, or before `addRow` has settled the
|
|
185
|
-
// row's id, the lookup returns null and `BoundTextInput` falls
|
|
186
|
-
// back to today's uncontrolled-input path.
|
|
187
|
-
textBinding: dotted
|
|
188
|
-
? resolveRowTextBinding(ctx, name)
|
|
189
|
-
: (ctx.textBindings?.get(name) ?? null),
|
|
190
141
|
}
|
|
191
142
|
}
|
|
192
143
|
|
|
193
|
-
/**
|
|
194
|
-
* Phase F.5c — dotted-name `TextBinding` resolver. Returns `null`
|
|
195
|
-
* whenever any precondition fails so the caller's renderer can take a
|
|
196
|
-
* single branch on null vs non-null.
|
|
197
|
-
*/
|
|
198
|
-
function resolveRowTextBinding(ctx: FormStateApi, dottedName: string): TextBinding | null {
|
|
199
|
-
if (!ctx.rowTextLeaves || !ctx.getRowTextBinding) return null
|
|
200
|
-
const parsed = parseRowFieldPath(dottedName)
|
|
201
|
-
if (!parsed) return null
|
|
202
|
-
const set = ctx.rowTextLeaves.get(parsed.arrayName)
|
|
203
|
-
if (!set?.has(parsed.fieldName)) return null
|
|
204
|
-
const rowId = rowIdAtIndex(ctx.values, parsed.arrayName, parsed.index)
|
|
205
|
-
if (!rowId) return null
|
|
206
|
-
return ctx.getRowTextBinding(parsed.arrayName, rowId, parsed.fieldName)
|
|
207
|
-
}
|
|
208
|
-
|
|
209
144
|
/** Response shape from `POST {base}/.../_form/:formId/state`. */
|
|
210
145
|
interface FormStateResponse {
|
|
211
146
|
ok: boolean
|
|
@@ -253,22 +188,11 @@ export function FormStateProvider({
|
|
|
253
188
|
const [errors, setErrors] = useState<Record<string, string[]>>(initialErrors)
|
|
254
189
|
const [pendingNames, setPendingNames] = useState<Set<string>>(() => new Set())
|
|
255
190
|
const [inFlight, setInFlight] = useState(false)
|
|
256
|
-
// Phase F.
|
|
257
|
-
//
|
|
258
|
-
// a ref) so consumers of
|
|
259
|
-
//
|
|
260
|
-
// existing `setValuesState` overlay below already triggers one when the
|
|
261
|
-
// room has pre-existing state.
|
|
262
|
-
const [textBindings, setTextBindings] = useState<ReadonlyMap<string, TextBinding> | null>(null)
|
|
263
|
-
// Phase F.5 — per-Repeater/Builder row-array stash. Same lifecycle as
|
|
264
|
-
// `textBindings`: populated on collab mount when the binding implements
|
|
265
|
-
// F.5 row methods, cleared on unmount.
|
|
191
|
+
// Phase F.5 — per-Repeater/Builder row-array stash. Populated on
|
|
192
|
+
// collab mount when the binding implements F.5 row methods, cleared
|
|
193
|
+
// on unmount. Stored in state (not a ref) so consumers of
|
|
194
|
+
// `useRowBinding` re-render once the bindings land.
|
|
266
195
|
const [rowBindings, setRowBindings] = useState<ReadonlyMap<string, RowBindingApi> | null>(null)
|
|
267
|
-
// Phase F.5c — per-array set of text-shaped row leaves + a pre-bound
|
|
268
|
-
// resolver. Both populated at binding mount when the active binding
|
|
269
|
-
// implements `getRowTextBinding`; cleared on unmount.
|
|
270
|
-
const [rowTextLeaves, setRowTextLeaves] = useState<ReadonlyMap<string, ReadonlySet<string>> | null>(null)
|
|
271
|
-
const [getRowTextBinding, setGetRowTextBinding] = useState<((arrayName: string, rowId: string, fieldName: string) => TextBinding | null) | null>(null)
|
|
272
196
|
|
|
273
197
|
const { notify } = useToast()
|
|
274
198
|
|
|
@@ -332,25 +256,6 @@ export function FormStateProvider({
|
|
|
332
256
|
setValuesState((prev) => ({ ...prev, ...synced }))
|
|
333
257
|
}
|
|
334
258
|
|
|
335
|
-
// Phase F.6 — ask the binding for a `TextBinding` on every top-level
|
|
336
|
-
// field name. The text/non-text allowlist lives in the binding impl,
|
|
337
|
-
// not in core: the binding returns `null` for non-text fields and
|
|
338
|
-
// text fields opted out via `.collab(false)`. `getTextBinding` is
|
|
339
|
-
// optional on the contract — F1-era plugins that haven't implemented
|
|
340
|
-
// it short-circuit the whole stash and every text field stays on the
|
|
341
|
-
// LWW path. We stash only the non-null answers. Cleanup is owned by
|
|
342
|
-
// `binding.destroy()` (expected to cascade into every issued
|
|
343
|
-
// `TextBinding`).
|
|
344
|
-
if (binding.getTextBinding) {
|
|
345
|
-
const textStash = new Map<string, TextBinding>()
|
|
346
|
-
for (const fieldName of Object.keys(valuesRef.current)) {
|
|
347
|
-
if (fieldName.includes('.')) continue
|
|
348
|
-
const tb = binding.getTextBinding(fieldName)
|
|
349
|
-
if (tb) textStash.set(fieldName, tb)
|
|
350
|
-
}
|
|
351
|
-
if (textStash.size > 0) setTextBindings(textStash)
|
|
352
|
-
}
|
|
353
|
-
|
|
354
259
|
// Phase F.5 — build a `RowBindingApi` per top-level Repeater/Builder
|
|
355
260
|
// field when the binding implements all three lifecycle methods. The
|
|
356
261
|
// walk reads from formMeta (structural — fields exist regardless of
|
|
@@ -358,8 +263,7 @@ export function FormStateProvider({
|
|
|
358
263
|
// the array name so `RepeaterInput` calls `rb.add(rowId, …)` rather
|
|
359
264
|
// than `binding.addRow(name, rowId, …)`. Partial F.5 impls (e.g. a
|
|
360
265
|
// binding that has addRow but not reorderRows) skip the stash — the
|
|
361
|
-
// contract says all three or nothing.
|
|
362
|
-
// exposed separately via `getRowTextBinding` and stays optional.
|
|
266
|
+
// contract says all three or nothing.
|
|
363
267
|
if (binding.addRow && binding.removeRow && binding.reorderRows) {
|
|
364
268
|
const { addRow, removeRow, reorderRows, subscribeRows } = binding
|
|
365
269
|
const arrayNames = collectRowArrayFieldNames(formMetaRef.current)
|
|
@@ -383,23 +287,6 @@ export function FormStateProvider({
|
|
|
383
287
|
}
|
|
384
288
|
}
|
|
385
289
|
|
|
386
|
-
// Phase F.5c — capture the per-array text-leaf allowlist + bind the
|
|
387
|
-
// row-text resolver to the active binding. `getRowTextBinding` may
|
|
388
|
-
// be absent on partial F.5 impls; we expose the resolver as null in
|
|
389
|
-
// that case so `useFieldState` short-circuits cleanly.
|
|
390
|
-
if (binding.getRowTextBinding) {
|
|
391
|
-
const leaves = collectRowTextLeavesByArray(formMetaRef.current)
|
|
392
|
-
if (leaves.size > 0) {
|
|
393
|
-
setRowTextLeaves(leaves)
|
|
394
|
-
const bound = binding.getRowTextBinding
|
|
395
|
-
// useState's functional-updater overload would invoke the
|
|
396
|
-
// stored function during set; wrapping in a fresh closure keeps
|
|
397
|
-
// React's setState path from confusing it for an updater fn.
|
|
398
|
-
setGetRowTextBinding(() => (arrayName: string, rowId: string, fieldName: string) =>
|
|
399
|
-
bound.call(binding, arrayName, rowId, fieldName))
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
290
|
// Subscribe to remote changes. Local writes ALSO trigger this
|
|
404
291
|
// (Yjs observers fire on local transactions too) — the per-key
|
|
405
292
|
// Object.is short-circuit below collapses them into no-op renders.
|
|
@@ -421,10 +308,7 @@ export function FormStateProvider({
|
|
|
421
308
|
unsubscribe()
|
|
422
309
|
binding.destroy()
|
|
423
310
|
bindingRef.current = null
|
|
424
|
-
setTextBindings(null)
|
|
425
311
|
setRowBindings(null)
|
|
426
|
-
setRowTextLeaves(null)
|
|
427
|
-
setGetRowTextBinding(null)
|
|
428
312
|
}
|
|
429
313
|
// `valuesRef.current` is intentionally read once at mount — initial
|
|
430
314
|
// values seed the binding; subsequent edits flow through `setValue`
|
|
@@ -657,11 +541,8 @@ export function FormStateProvider({
|
|
|
657
541
|
formMeta,
|
|
658
542
|
inFlight,
|
|
659
543
|
fieldStatus,
|
|
660
|
-
textBindings,
|
|
661
544
|
rowBindings,
|
|
662
|
-
|
|
663
|
-
getRowTextBinding,
|
|
664
|
-
}), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, textBindings, rowBindings, rowTextLeaves, getRowTextBinding])
|
|
545
|
+
}), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, rowBindings])
|
|
665
546
|
|
|
666
547
|
return (
|
|
667
548
|
<FormStateContext.Provider value={api}>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 1 — row-text Tiptap-backed collab plan
|
|
5
|
+
* (`pilotiq-pro/docs/plans/collab-row-text-tiptap-backed.md`).
|
|
6
|
+
*
|
|
7
|
+
* Each Repeater / Builder row mounts a `<RowCoordsContext.Provider>`
|
|
8
|
+
* around its children so dotted-path text leaves can compose a
|
|
9
|
+
* fragment-key that includes the stable `rowId` (survives reorders).
|
|
10
|
+
* Top-level fields see `null` and fall through to bare-name fragment
|
|
11
|
+
* routing.
|
|
12
|
+
*/
|
|
13
|
+
export interface RowCoords {
|
|
14
|
+
arrayName: string
|
|
15
|
+
rowIndex: number
|
|
16
|
+
rowId: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const RowCoordsContext = createContext<RowCoords | null>(null)
|
|
20
|
+
|
|
21
|
+
export function useRowCoords(): RowCoords | null {
|
|
22
|
+
return useContext(RowCoordsContext)
|
|
23
|
+
}
|
|
@@ -5,6 +5,7 @@ import { Button } from '../ui/button.js'
|
|
|
5
5
|
import { SchemaRenderer } from '../SchemaRenderer.js'
|
|
6
6
|
import { FormIdContext, useFormState, useRowBinding } from '../FormStateContext.js'
|
|
7
7
|
import { findFieldMeta } from '../formStateHelpers.js'
|
|
8
|
+
import { RowCoordsContext } from '../RowCoordsContext.js'
|
|
8
9
|
import { useIconFor } from '../icon-context.js'
|
|
9
10
|
import { reorderRows, ExtraActionStrip, buildGridContainer } from './RepeaterInput.js'
|
|
10
11
|
import { syncRowGates } from './syncRowGates.js'
|
|
@@ -191,11 +192,9 @@ export function BuilderInput({
|
|
|
191
192
|
// the first event — without it, the picker dropdown choice doesn't
|
|
192
193
|
// propagate until the user makes their first inner-field edit.
|
|
193
194
|
const rowBinding = useRowBinding(name)
|
|
194
|
-
//
|
|
195
|
-
// row
|
|
196
|
-
//
|
|
197
|
-
// Without this stamp the F.5c per-row Y.Text path stays null on Builder
|
|
198
|
-
// and inner text fields never sync. Mirrors the same fix in RepeaterInput.
|
|
195
|
+
// Mirror row identities into the form's values map so dotted row-leaf
|
|
196
|
+
// consumers can resolve the row's `__id` via `rowIdAtIndex(ctx.values,
|
|
197
|
+
// name, i)`. Mirrors the same plumbing in RepeaterInput.
|
|
199
198
|
const formStateForIds = useFormState()
|
|
200
199
|
const ctxSetValue = formStateForIds?.setValue
|
|
201
200
|
useEffect(() => {
|
|
@@ -870,6 +869,15 @@ function BuilderRow({
|
|
|
870
869
|
() => row.children.map(c => prefixFieldNames(c, dataPrefix)),
|
|
871
870
|
[row.children, dataPrefix],
|
|
872
871
|
)
|
|
872
|
+
// Row coords for dotted-path text leaves under this row — composes
|
|
873
|
+
// fragment-key `${arrayName}.${rowId}.${fieldName}` (Phase 1 of
|
|
874
|
+
// collab-row-text-tiptap-backed.md). `parseRowFieldPath` strips the
|
|
875
|
+
// Builder-specific `data` segment, so the coords use the array name +
|
|
876
|
+
// the row's stable id without referencing the dialect.
|
|
877
|
+
const rowCoords = useMemo(
|
|
878
|
+
() => ({ arrayName: name, rowIndex: index, rowId: row.id }),
|
|
879
|
+
[name, index, row.id],
|
|
880
|
+
)
|
|
873
881
|
|
|
874
882
|
const RowIcon = useIconFor(showIcons ? block?.icon : undefined)
|
|
875
883
|
const blockLabel = block?.label ?? row.type ?? 'Block'
|
|
@@ -878,11 +886,13 @@ function BuilderRow({
|
|
|
878
886
|
|
|
879
887
|
if (row.hidden) {
|
|
880
888
|
return (
|
|
881
|
-
<
|
|
882
|
-
<
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
889
|
+
<RowCoordsContext.Provider value={rowCoords}>
|
|
890
|
+
<div style={{ display: 'none' }} data-pilotiq-builder-row="hidden">
|
|
891
|
+
<input type="hidden" name={`${name}.${index}.__id`} value={row.id} readOnly />
|
|
892
|
+
<input type="hidden" name={`${name}.${index}.type`} value={row.type} readOnly />
|
|
893
|
+
<SchemaRenderer elements={namespaced} />
|
|
894
|
+
</div>
|
|
895
|
+
</RowCoordsContext.Provider>
|
|
886
896
|
)
|
|
887
897
|
}
|
|
888
898
|
|
|
@@ -921,6 +931,7 @@ function BuilderRow({
|
|
|
921
931
|
const innerColumns = block.columns && block.columns > 1 ? block.columns : 1
|
|
922
932
|
|
|
923
933
|
return (
|
|
934
|
+
<RowCoordsContext.Provider value={rowCoords}>
|
|
924
935
|
<div
|
|
925
936
|
className={`rounded-md border bg-card transition-opacity ${isDragging ? 'opacity-50' : ''}`}
|
|
926
937
|
data-pilotiq-builder-row=""
|
|
@@ -999,6 +1010,7 @@ function BuilderRow({
|
|
|
999
1010
|
: <SchemaRenderer elements={namespaced} />}
|
|
1000
1011
|
</div>
|
|
1001
1012
|
</div>
|
|
1013
|
+
</RowCoordsContext.Provider>
|
|
1002
1014
|
)
|
|
1003
1015
|
}
|
|
1004
1016
|
|