@pilotiq/pilotiq 0.10.0 → 0.11.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 +120 -0
- package/dist/react/FormCollabBindingRegistry.d.ts +130 -0
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
- package/dist/react/FormCollabBindingRegistry.js.map +1 -1
- package/dist/react/FormStateContext.d.ts +39 -1
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +126 -33
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/fields/BuilderInput.d.ts.map +1 -1
- package/dist/react/fields/BuilderInput.js +112 -10
- package/dist/react/fields/BuilderInput.js.map +1 -1
- package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
- package/dist/react/fields/RepeaterInput.js +113 -10
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/formStateHelpers.d.ts +102 -0
- package/dist/react/formStateHelpers.d.ts.map +1 -1
- package/dist/react/formStateHelpers.js +234 -0
- package/dist/react/formStateHelpers.js.map +1 -1
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -1
- package/dist/react/index.js.map +1 -1
- package/package.json +1 -1
- package/src/react/FormCollabBindingRegistry.ts +129 -0
- package/src/react/FormStateContext.tsx +157 -34
- package/src/react/fields/BuilderInput.tsx +97 -8
- package/src/react/fields/RepeaterInput.tsx +97 -8
- package/src/react/formStateHelpers.test.ts +312 -0
- package/src/react/formStateHelpers.ts +246 -0
- package/src/react/index.ts +3 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ElementMeta } from '../schema/Element.js'
|
|
2
|
+
import type { FormCollabBinding } from './FormCollabBindingRegistry.js'
|
|
2
3
|
|
|
3
4
|
/** Walk a form's child tree and collect every Field's `defaultValue` into
|
|
4
5
|
* a flat values map keyed by field name. Used by `FormStateProvider` to
|
|
@@ -216,3 +217,248 @@ export function readNestedValue(
|
|
|
216
217
|
}
|
|
217
218
|
return cursor
|
|
218
219
|
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Phase F.5 — parsed row-field coordinates.
|
|
223
|
+
*
|
|
224
|
+
* `fieldName` is the leaf field's name as declared in the inner schema
|
|
225
|
+
* (e.g. the `TextField.make('label')` inside a `Repeater.schema([...])`).
|
|
226
|
+
* Builder rows wrap their inner schema under a literal `data` segment;
|
|
227
|
+
* `parseRowFieldPath` strips that segment so consumers don't need to
|
|
228
|
+
* know which row-array dialect they're addressing.
|
|
229
|
+
*/
|
|
230
|
+
export interface ParsedRowFieldPath {
|
|
231
|
+
arrayName: string
|
|
232
|
+
index: number
|
|
233
|
+
fieldName: string
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Phase F.5 — parse a dotted form-field name into row-array coordinates
|
|
238
|
+
* when it matches a Repeater or Builder row-leaf shape; return `null`
|
|
239
|
+
* otherwise. Used by `FormStateProvider` to route row-leaf writes
|
|
240
|
+
* through `FormCollabBinding.setRow` instead of skipping them.
|
|
241
|
+
*
|
|
242
|
+
* `tags.0.label` → { arrayName: 'tags', index: 0, fieldName: 'label' } (Repeater)
|
|
243
|
+
* `blocks.0.data.body` → { arrayName: 'blocks', index: 0, fieldName: 'body' } (Builder)
|
|
244
|
+
* `tags.0.__id` → null (row identity — handled by addRow / reorderRows)
|
|
245
|
+
* `blocks.0.type` → null (Builder block discriminator — not a user field)
|
|
246
|
+
* `foo` → null (top-level field — top-level binding.set path)
|
|
247
|
+
* `tags.notnum.label` → null (malformed)
|
|
248
|
+
*
|
|
249
|
+
* Nested Repeaters / Builders (e.g. `articles.0.comments.1.body`) are
|
|
250
|
+
* out of scope v1 — they return `null` here too. F.5b's binding impl
|
|
251
|
+
* therefore never sees them and they continue to ride the local-state
|
|
252
|
+
* path until a future phase opens the door.
|
|
253
|
+
*/
|
|
254
|
+
export function parseRowFieldPath(path: string): ParsedRowFieldPath | null {
|
|
255
|
+
const segments = path.split('.')
|
|
256
|
+
if (segments.length === 3) {
|
|
257
|
+
const [arrayName, idxRaw, fieldName] = segments as [string, string, string]
|
|
258
|
+
if (!/^\d+$/.test(idxRaw)) return null
|
|
259
|
+
if (!arrayName || !fieldName) return null
|
|
260
|
+
if (fieldName === '__id' || fieldName === 'type') return null
|
|
261
|
+
return { arrayName, index: Number(idxRaw), fieldName }
|
|
262
|
+
}
|
|
263
|
+
if (segments.length === 4 && segments[2] === 'data') {
|
|
264
|
+
const [arrayName, idxRaw, , fieldName] = segments as [string, string, string, string]
|
|
265
|
+
if (!/^\d+$/.test(idxRaw)) return null
|
|
266
|
+
if (!arrayName || !fieldName) return null
|
|
267
|
+
return { arrayName, index: Number(idxRaw), fieldName }
|
|
268
|
+
}
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Phase F.5 — resolve the stable `__id` of the row at `arrayName[index]`
|
|
274
|
+
* from a flat dotted-path values map. The renderer mints `__id` on every
|
|
275
|
+
* row insert (UUID for new / DB PK for relationship-backed) so callers
|
|
276
|
+
* here are looking up an id that already exists; returns `null` only
|
|
277
|
+
* when the row hasn't been registered yet (e.g. the binding observed a
|
|
278
|
+
* remote insert before the local renderer rebuilt its row map).
|
|
279
|
+
*
|
|
280
|
+
* The lookup always reads from the flat shape (`${arrayName}.${index}.__id`)
|
|
281
|
+
* since both `FormStateProvider`'s `valuesRef` and server-resolve responses
|
|
282
|
+
* use flat dotted-path keys.
|
|
283
|
+
*/
|
|
284
|
+
export function rowIdAtIndex(
|
|
285
|
+
values: Record<string, unknown>,
|
|
286
|
+
arrayName: string,
|
|
287
|
+
index: number,
|
|
288
|
+
): string | null {
|
|
289
|
+
const flat = values[`${arrayName}.${index}.__id`]
|
|
290
|
+
if (typeof flat === 'string' && flat.length > 0) return flat
|
|
291
|
+
return null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Phase F2 — returns `true` iff the named field has explicitly opted out
|
|
296
|
+
* of realtime collab via `Field.collab(false)`. Sparse meta — absent =
|
|
297
|
+
* inherit the panel default (collab on). Walks the form meta tree the
|
|
298
|
+
* same way `findFieldMeta` does. Cheap (one map lookup per write).
|
|
299
|
+
*
|
|
300
|
+
* Exposed so `routeBindingWrite` can compose against it from this
|
|
301
|
+
* module instead of FormStateContext's private helper.
|
|
302
|
+
*/
|
|
303
|
+
export function fieldOptsOutOfCollab(formMeta: ElementMeta, name: string): boolean {
|
|
304
|
+
const meta = findFieldMeta(formMeta, name) as { collab?: boolean } | undefined
|
|
305
|
+
return meta?.collab === false
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Phase F.5 — route a single (name, value) write through the collab
|
|
310
|
+
* binding by name shape:
|
|
311
|
+
*
|
|
312
|
+
* - top-level name → `binding.set(name, value)` (F2 path)
|
|
313
|
+
* - row leaf → `binding.setRow(arrayName, rowId, fieldName, value)`
|
|
314
|
+
* when the binding implements F.5; falls through
|
|
315
|
+
* to no-op otherwise (v1 skip-on-dot behaviour).
|
|
316
|
+
*
|
|
317
|
+
* Skips when no binding is registered or the field opted out via
|
|
318
|
+
* `.collab(false)`. `valuesForLookup` supplies the rowId map — pass the
|
|
319
|
+
* latest snapshot the caller has access to (`valuesRef.current` for
|
|
320
|
+
* local writes; merged `valuesRef + serverValues` for server-resolve).
|
|
321
|
+
*
|
|
322
|
+
* Shared between `setValue` (local edits) and the server-resolve overlay
|
|
323
|
+
* inside `performLivePost` so both routing decisions stay in lockstep.
|
|
324
|
+
*/
|
|
325
|
+
export function routeBindingWrite(
|
|
326
|
+
binding: FormCollabBinding | null,
|
|
327
|
+
formMeta: ElementMeta | undefined,
|
|
328
|
+
valuesForLookup: Record<string, unknown>,
|
|
329
|
+
name: string,
|
|
330
|
+
value: unknown,
|
|
331
|
+
): void {
|
|
332
|
+
if (!binding) return
|
|
333
|
+
if (formMeta && fieldOptsOutOfCollab(formMeta, name)) return
|
|
334
|
+
if (!name.includes('.')) {
|
|
335
|
+
binding.set(name, value)
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
if (!binding.setRow) return // pre-F.5 binding — row leaves stay local
|
|
339
|
+
const parsed = parseRowFieldPath(name)
|
|
340
|
+
if (!parsed) return // nested-Repeater / malformed — out of scope v1
|
|
341
|
+
const rowId = rowIdAtIndex(valuesForLookup, parsed.arrayName, parsed.index)
|
|
342
|
+
if (!rowId) return // row not yet stamped — local-only until id lands
|
|
343
|
+
binding.setRow(parsed.arrayName, rowId, parsed.fieldName, value)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Phase F.5 — walk a form's meta tree for top-level Repeater / Builder
|
|
348
|
+
* field names. Used by `FormStateProvider` to build the per-array
|
|
349
|
+
* `RowBindingApi` map from a single meta walk at binding mount.
|
|
350
|
+
*
|
|
351
|
+
* Stops at the array boundary itself — we want the array's own field
|
|
352
|
+
* name, not the inner-row fields inside it (which address through
|
|
353
|
+
* `setRow`'s dotted-path routing, not through their own row bindings).
|
|
354
|
+
* Skips fields opted out via `.collab(false)` so the array is left on
|
|
355
|
+
* the v1 local-state path.
|
|
356
|
+
*/
|
|
357
|
+
export function collectRowArrayFieldNames(formMeta: ElementMeta): string[] {
|
|
358
|
+
const out: string[] = []
|
|
359
|
+
walk(formMeta)
|
|
360
|
+
return out
|
|
361
|
+
|
|
362
|
+
function walk(node: ElementMeta): void {
|
|
363
|
+
if (node.type === 'field') {
|
|
364
|
+
const fieldType = node['fieldType']
|
|
365
|
+
if (fieldType === 'repeater' || fieldType === 'builder') {
|
|
366
|
+
if ((node as { collab?: boolean }).collab !== false) {
|
|
367
|
+
const name = String(node['name'] ?? '')
|
|
368
|
+
if (name) out.push(name)
|
|
369
|
+
}
|
|
370
|
+
// Don't descend — inner row fields address through dotted-path
|
|
371
|
+
// routing, not their own row bindings.
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const children = node.children
|
|
376
|
+
if (Array.isArray(children)) {
|
|
377
|
+
for (const child of children) walk(child as ElementMeta)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Phase F.5c — text-shaped fieldTypes whose row-leaf values should be
|
|
384
|
+
* routed through `Y.Text` instead of `Y.Map` LWW. Mirrors the same
|
|
385
|
+
* allowlist `@pilotiq-pro/collab`'s top-level binding uses; consumers
|
|
386
|
+
* registering character-level CRDT for additional plain-text-shaped
|
|
387
|
+
* fields update both copies in lockstep until a cross-repo shared
|
|
388
|
+
* constants module exists.
|
|
389
|
+
*/
|
|
390
|
+
const ROW_TEXT_FIELD_TYPES: ReadonlySet<string> = new Set([
|
|
391
|
+
'text', 'textarea', 'email', 'slug', 'markdown',
|
|
392
|
+
])
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Phase F.5c — per-Repeater/Builder set of inner-field names that
|
|
396
|
+
* carry text-shaped leaves eligible for character-level CRDT. Drives
|
|
397
|
+
* `useFieldState(dottedName).textBinding` resolution: only fields in
|
|
398
|
+
* the per-array set go through `binding.getRowTextBinding`; everything
|
|
399
|
+
* else stays on row-level Y.Map LWW.
|
|
400
|
+
*
|
|
401
|
+
* Repeater rows expose their schema directly under `meta.children`;
|
|
402
|
+
* Builder rows nest schemas under `meta.blocks[i].template`. The
|
|
403
|
+
* walker descends through every block's template so a `markdown` leaf
|
|
404
|
+
* inside any block-type lands in the array's allowlist. Nested
|
|
405
|
+
* Repeaters / Builders inside row schemas are out of scope v1 (their
|
|
406
|
+
* dotted paths are 5+ segments and `parseRowFieldPath` rejects them).
|
|
407
|
+
*/
|
|
408
|
+
export function collectRowTextLeavesByArray(formMeta: ElementMeta): Map<string, Set<string>> {
|
|
409
|
+
const out = new Map<string, Set<string>>()
|
|
410
|
+
walkTop(formMeta)
|
|
411
|
+
return out
|
|
412
|
+
|
|
413
|
+
function walkTop(node: ElementMeta): void {
|
|
414
|
+
if (node.type === 'field') {
|
|
415
|
+
const fieldType = String(node['fieldType'] ?? '')
|
|
416
|
+
if (fieldType === 'repeater' || fieldType === 'builder') {
|
|
417
|
+
if ((node as { collab?: boolean }).collab === false) return
|
|
418
|
+
const name = String(node['name'] ?? '')
|
|
419
|
+
if (!name) return
|
|
420
|
+
const set = new Set<string>()
|
|
421
|
+
// Repeater's `toMeta()` emits the row schema under `template` (not
|
|
422
|
+
// `children` — that's per-resolved-row). Builder nests row schemas
|
|
423
|
+
// under `blocks[i].template`. Reading `children` here pre-fix gave
|
|
424
|
+
// every Repeater an empty text-leaf set → row text never CRDT'd.
|
|
425
|
+
if (fieldType === 'repeater') walkRow((node as { template?: unknown }).template, set)
|
|
426
|
+
else walkBlocks((node as { blocks?: unknown }).blocks, set)
|
|
427
|
+
if (set.size > 0) out.set(name, set)
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const children = node.children
|
|
432
|
+
if (Array.isArray(children)) {
|
|
433
|
+
for (const child of children) walkTop(child as ElementMeta)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function walkRow(children: unknown, set: Set<string>): void {
|
|
438
|
+
if (!Array.isArray(children)) return
|
|
439
|
+
for (const child of children) walkRowEl(child as ElementMeta, set)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function walkRowEl(node: ElementMeta, set: Set<string>): void {
|
|
443
|
+
if (node.type === 'field') {
|
|
444
|
+
const fieldType = String(node['fieldType'] ?? '')
|
|
445
|
+
if (fieldType === 'repeater' || fieldType === 'builder') return // nested array
|
|
446
|
+
if ((node as { collab?: boolean }).collab === false) return
|
|
447
|
+
const name = String(node['name'] ?? '')
|
|
448
|
+
if (name && ROW_TEXT_FIELD_TYPES.has(fieldType)) set.add(name)
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
const children = node.children
|
|
452
|
+
if (Array.isArray(children)) {
|
|
453
|
+
for (const child of children) walkRowEl(child as ElementMeta, set)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function walkBlocks(blocks: unknown, set: Set<string>): void {
|
|
458
|
+
if (!Array.isArray(blocks)) return
|
|
459
|
+
for (const block of blocks) {
|
|
460
|
+
const tpl = (block as { template?: unknown }).template
|
|
461
|
+
walkRow(tpl, set)
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -51,6 +51,8 @@ export {
|
|
|
51
51
|
type FormCollabBindingFactoryArgs,
|
|
52
52
|
type TextBinding,
|
|
53
53
|
type TextDelta,
|
|
54
|
+
type RowsEvent,
|
|
55
|
+
type RowBindingApi,
|
|
54
56
|
} from './FormCollabBindingRegistry.js'
|
|
55
57
|
export {
|
|
56
58
|
registerFieldPresenceComponent,
|
|
@@ -89,6 +91,7 @@ export {
|
|
|
89
91
|
FormStateProvider,
|
|
90
92
|
useFieldState,
|
|
91
93
|
useFormState,
|
|
94
|
+
useRowBinding,
|
|
92
95
|
type FormStateApi,
|
|
93
96
|
type FormStateProviderProps,
|
|
94
97
|
type UseFieldStateResult,
|