@pilotiq/pilotiq 0.13.0 → 0.14.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 +19 -0
- package/dist/Page.d.ts +40 -0
- package/dist/Page.d.ts.map +1 -1
- package/dist/Page.js +32 -0
- package/dist/Page.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pageData/helpers.d.ts +16 -0
- package/dist/pageData/helpers.d.ts.map +1 -1
- package/dist/pageData/helpers.js +61 -1
- package/dist/pageData/helpers.js.map +1 -1
- package/dist/pageData/navigation.d.ts +15 -1
- package/dist/pageData/navigation.d.ts.map +1 -1
- package/dist/pageData/navigation.js +15 -0
- package/dist/pageData/navigation.js.map +1 -1
- package/dist/pageData.d.ts +1 -1
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +1 -1
- package/dist/pageData.js.map +1 -1
- package/dist/react/AppShell.d.ts +5 -0
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js +2 -1
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/CustomPageWrapperGate.d.ts +33 -0
- package/dist/react/CustomPageWrapperGate.d.ts.map +1 -0
- package/dist/react/CustomPageWrapperGate.js +50 -0
- package/dist/react/CustomPageWrapperGate.js.map +1 -0
- package/dist/react/CustomPageWrapperRegistry.d.ts +34 -0
- package/dist/react/CustomPageWrapperRegistry.d.ts.map +1 -0
- package/dist/react/CustomPageWrapperRegistry.js +19 -0
- package/dist/react/CustomPageWrapperRegistry.js.map +1 -0
- package/dist/react/FormCollabBindingRegistry.d.ts +16 -0
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
- package/dist/react/FormCollabBindingRegistry.js.map +1 -1
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +8 -1
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/fields/BuilderInput.d.ts.map +1 -1
- package/dist/react/fields/BuilderInput.js +64 -40
- 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 +78 -43
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/fields/repeaterReconcile.d.ts +66 -0
- package/dist/react/fields/repeaterReconcile.d.ts.map +1 -0
- package/dist/react/fields/repeaterReconcile.js +96 -0
- package/dist/react/fields/repeaterReconcile.js.map +1 -0
- package/dist/react/index.d.ts +2 -0
- 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/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -1
- package/dist/react/schemaRenderer/form/FormRenderer.js +10 -0
- package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -1
- package/package.json +1 -1
- package/src/Page.test.ts +64 -0
- package/src/Page.ts +60 -0
- package/src/index.ts +1 -1
- package/src/pageData/helpers.ts +55 -1
- package/src/pageData/navigation.ts +31 -1
- package/src/pageData.test.ts +109 -0
- package/src/pageData.ts +1 -0
- package/src/react/AppShell.tsx +12 -1
- package/src/react/CustomPageWrapperGate.tsx +69 -0
- package/src/react/CustomPageWrapperRegistry.ts +45 -0
- package/src/react/FormCollabBindingRegistry.ts +17 -0
- package/src/react/FormStateContext.tsx +8 -1
- package/src/react/fields/BuilderInput.tsx +53 -29
- package/src/react/fields/RepeaterInput.tsx +66 -32
- package/src/react/fields/repeaterReconcile.test.ts +114 -0
- package/src/react/fields/repeaterReconcile.ts +104 -0
- package/src/react/index.ts +10 -0
- package/src/react/schemaRenderer/form/FormRenderer.tsx +10 -0
package/src/pageData.test.ts
CHANGED
|
@@ -83,6 +83,73 @@ describe('applyFillPipeline', () => {
|
|
|
83
83
|
const values = await applyFillPipeline(form, { id: 1 })
|
|
84
84
|
assert.deepEqual(values, { id: 1, async: true })
|
|
85
85
|
})
|
|
86
|
+
|
|
87
|
+
it('parses JSON-string values on Repeater slots into arrays', async () => {
|
|
88
|
+
const form = Form.make().schema([
|
|
89
|
+
TextField.make('title'),
|
|
90
|
+
Repeater.make('metadata').schema([TextField.make('heading')]),
|
|
91
|
+
])
|
|
92
|
+
const record = {
|
|
93
|
+
id: 1,
|
|
94
|
+
title: 'Hello',
|
|
95
|
+
metadata: '[{"__id":"row-1","heading":"a"},{"__id":"row-2","heading":"b"}]',
|
|
96
|
+
}
|
|
97
|
+
const values = await applyFillPipeline(form, record)
|
|
98
|
+
assert.deepEqual(values['metadata'], [
|
|
99
|
+
{ __id: 'row-1', heading: 'a' },
|
|
100
|
+
{ __id: 'row-2', heading: 'b' },
|
|
101
|
+
])
|
|
102
|
+
assert.equal(values['title'], 'Hello')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('parses JSON-string values on Builder slots into arrays', async () => {
|
|
106
|
+
const form = Form.make().schema([
|
|
107
|
+
Builder.make('content').blocks([
|
|
108
|
+
Block.make('heading').schema([TextField.make('text')]),
|
|
109
|
+
]),
|
|
110
|
+
])
|
|
111
|
+
const record = {
|
|
112
|
+
content: '[{"__id":"row-1","type":"heading","data":{"text":"hi"}}]',
|
|
113
|
+
}
|
|
114
|
+
const values = await applyFillPipeline(form, record)
|
|
115
|
+
assert.deepEqual(values['content'], [
|
|
116
|
+
{ __id: 'row-1', type: 'heading', data: { text: 'hi' } },
|
|
117
|
+
])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('leaves non-JSON strings on array-field slots untouched', async () => {
|
|
121
|
+
const form = Form.make().schema([
|
|
122
|
+
Repeater.make('tags').schema([TextField.make('label')]),
|
|
123
|
+
])
|
|
124
|
+
const record = { tags: 'not-json' }
|
|
125
|
+
const values = await applyFillPipeline(form, record)
|
|
126
|
+
assert.equal(values['tags'], 'not-json')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('leaves JSON strings that deserialize to non-arrays untouched', async () => {
|
|
130
|
+
const form = Form.make().schema([
|
|
131
|
+
Repeater.make('tags').schema([TextField.make('label')]),
|
|
132
|
+
])
|
|
133
|
+
const record = { tags: '{"not":"an-array"}' }
|
|
134
|
+
const values = await applyFillPipeline(form, record)
|
|
135
|
+
assert.equal(values['tags'], '{"not":"an-array"}')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('passes through already-parsed arrays unchanged', async () => {
|
|
139
|
+
const form = Form.make().schema([
|
|
140
|
+
Repeater.make('metadata').schema([TextField.make('heading')]),
|
|
141
|
+
])
|
|
142
|
+
const rows = [{ __id: 'row-1', heading: 'a' }]
|
|
143
|
+
const values = await applyFillPipeline(form, { metadata: rows })
|
|
144
|
+
assert.equal(values['metadata'], rows)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('ignores top-level non-array fields whose value happens to be a JSON-string', async () => {
|
|
148
|
+
const form = Form.make().schema([TextField.make('title')])
|
|
149
|
+
const record = { title: '[1,2,3]' }
|
|
150
|
+
const values = await applyFillPipeline(form, record)
|
|
151
|
+
assert.equal(values['title'], '[1,2,3]')
|
|
152
|
+
})
|
|
86
153
|
})
|
|
87
154
|
|
|
88
155
|
describe('resolveActiveTab', () => {
|
|
@@ -1339,3 +1406,45 @@ describe('panelInfo — recordCollab map (resource collab opt-in)', () => {
|
|
|
1339
1406
|
})
|
|
1340
1407
|
})
|
|
1341
1408
|
})
|
|
1409
|
+
|
|
1410
|
+
describe('panelInfo — pageCollab map (custom-page collab opt-in)', () => {
|
|
1411
|
+
it('absent when no page opts in', async () => {
|
|
1412
|
+
class Analytics extends Page {
|
|
1413
|
+
static override slug = 'analytics'
|
|
1414
|
+
static override label = 'Analytics'
|
|
1415
|
+
}
|
|
1416
|
+
const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Analytics]))
|
|
1417
|
+
assert.equal((info as { pageCollab?: unknown }).pageCollab, undefined)
|
|
1418
|
+
})
|
|
1419
|
+
|
|
1420
|
+
it('emits an entry per opted-in custom page keyed by URL slug', async () => {
|
|
1421
|
+
class Settings extends Page {
|
|
1422
|
+
static override slug = 'settings'
|
|
1423
|
+
static override label = 'Settings'
|
|
1424
|
+
static override collab = { room: 'settings-general' }
|
|
1425
|
+
}
|
|
1426
|
+
class Analytics extends Page {
|
|
1427
|
+
static override slug = 'analytics'
|
|
1428
|
+
static override label = 'Analytics'
|
|
1429
|
+
// No collab — should NOT appear in the map.
|
|
1430
|
+
}
|
|
1431
|
+
const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Settings, Analytics]))
|
|
1432
|
+
const map = (info as { pageCollab?: Record<string, unknown> }).pageCollab
|
|
1433
|
+
assert.deepEqual(map, {
|
|
1434
|
+
settings: { room: 'settings-general', presence: true },
|
|
1435
|
+
})
|
|
1436
|
+
})
|
|
1437
|
+
|
|
1438
|
+
it('object form can suppress presence', async () => {
|
|
1439
|
+
class Settings extends Page {
|
|
1440
|
+
static override slug = 'settings'
|
|
1441
|
+
static override label = 'Settings'
|
|
1442
|
+
static override collab = { room: 'settings', presence: false }
|
|
1443
|
+
}
|
|
1444
|
+
const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Settings]))
|
|
1445
|
+
const map = (info as { pageCollab?: Record<string, unknown> }).pageCollab
|
|
1446
|
+
assert.deepEqual(map, {
|
|
1447
|
+
settings: { room: 'settings', presence: false },
|
|
1448
|
+
})
|
|
1449
|
+
})
|
|
1450
|
+
})
|
package/src/pageData.ts
CHANGED
package/src/react/AppShell.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { RightPanelRegistryProvider } from './right-panel-registry.js'
|
|
|
11
11
|
import { RightSidebarProvider, useRightSidebarOptional } from './RightSidebarContext.js'
|
|
12
12
|
import { RightSidebar } from './RightSidebar.js'
|
|
13
13
|
import { RecordWrapperGate, type RecordCollabMap } from './RecordWrapperGate.js'
|
|
14
|
+
import { CustomPageWrapperGate, type PageCollabMap } from './CustomPageWrapperGate.js'
|
|
14
15
|
import { useIsMobile } from './hooks/use-mobile.js'
|
|
15
16
|
import type { NavItem, UserMenuMeta, DatabaseNotificationsMeta, RightSidebarMeta } from '../pageData.js'
|
|
16
17
|
import type { RenderHookMap } from '../RenderHook.js'
|
|
@@ -39,6 +40,10 @@ export interface AppShellProps {
|
|
|
39
40
|
* decide whether to mount the plugin-registered RecordWrapper on
|
|
40
41
|
* a record edit/view URL. Absent when no resource opted in. */
|
|
41
42
|
recordCollab?: RecordCollabMap
|
|
43
|
+
/** Per-custom-page collab opt-in map — read by `CustomPageWrapperGate`
|
|
44
|
+
* to decide whether to mount the plugin-registered CustomPageWrapper
|
|
45
|
+
* on a custom-page URL. Absent when no page opted in. */
|
|
46
|
+
pageCollab?: PageCollabMap
|
|
42
47
|
/** Pre-resolved render-hook slots for the panel chrome (body /
|
|
43
48
|
* topbar / sidebar / user-menu / footer / head). Sparse map —
|
|
44
49
|
* slots with no registered entries are absent. Built by
|
|
@@ -137,7 +142,13 @@ export function AppShell({ layout = 'sidebar', notifications, componentRegistry,
|
|
|
137
142
|
{...(props.currentPath !== undefined ? { currentPath: props.currentPath } : {})}
|
|
138
143
|
{...(props.panel.recordCollab !== undefined ? { recordCollab: props.panel.recordCollab } : {})}
|
|
139
144
|
>
|
|
140
|
-
|
|
145
|
+
<CustomPageWrapperGate
|
|
146
|
+
basePath={props.basePath}
|
|
147
|
+
{...(props.currentPath !== undefined ? { currentPath: props.currentPath } : {})}
|
|
148
|
+
{...(props.panel.pageCollab !== undefined ? { pageCollab: props.panel.pageCollab } : {})}
|
|
149
|
+
>
|
|
150
|
+
{props.children}
|
|
151
|
+
</CustomPageWrapperGate>
|
|
141
152
|
</RecordWrapperGate>
|
|
142
153
|
),
|
|
143
154
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type ReactNode } from 'react'
|
|
2
|
+
import { getCustomPageWrapper } from './CustomPageWrapperRegistry.js'
|
|
3
|
+
import type { PageCollabConfig } from '../Page.js'
|
|
4
|
+
|
|
5
|
+
/** Per-custom-page collab opt-in keyed by URL slug (`P.getSlug()` for
|
|
6
|
+
* non-clustered, `${cluster.slug}/${P.getSlug()}` for clustered). Built
|
|
7
|
+
* server-side by `panelInfo()` as `pageCollab`. */
|
|
8
|
+
export type PageCollabMap = Record<string, PageCollabConfig>
|
|
9
|
+
|
|
10
|
+
export interface CustomPageWrapperGateProps {
|
|
11
|
+
currentPath?: string
|
|
12
|
+
basePath: string
|
|
13
|
+
/** Per-page opt-in map. Absent means no custom page opted in — gate
|
|
14
|
+
* always passes through. */
|
|
15
|
+
pageCollab?: PageCollabMap
|
|
16
|
+
children: ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Strip `basePath` from `currentPath` and return the remaining slash-
|
|
21
|
+
* joined tail. Returns `null` when the path is empty / doesn't start
|
|
22
|
+
* with `basePath` / has no tail. Mirrors `parseRecordPageUrl`'s
|
|
23
|
+
* normalization (trailing slash on either side is tolerated).
|
|
24
|
+
*/
|
|
25
|
+
function pageSlugFromUrl(currentPath: string, basePath: string): string | null {
|
|
26
|
+
if (!currentPath) return null
|
|
27
|
+
const trimmedPath = currentPath.replace(/\/+$/, '')
|
|
28
|
+
const trimmedBase = basePath.replace(/\/+$/, '')
|
|
29
|
+
|
|
30
|
+
if (trimmedBase !== '' && !trimmedPath.startsWith(trimmedBase)) return null
|
|
31
|
+
|
|
32
|
+
const tail = trimmedPath.slice(trimmedBase.length).replace(/^\/+/, '')
|
|
33
|
+
if (tail.length === 0) return null
|
|
34
|
+
return tail
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Conditionally wraps the page tree with the plugin-registered
|
|
39
|
+
* `CustomPageWrapper` when the current URL resolves to a custom page
|
|
40
|
+
* (a `Page` subclass with `static collab = { room: '…' }`).
|
|
41
|
+
* Pass-through in every other case:
|
|
42
|
+
*
|
|
43
|
+
* - no plugin registered a wrapper (`getCustomPageWrapper() === null`)
|
|
44
|
+
* - `currentPath` not yet known on the very first SSR render
|
|
45
|
+
* - `pageCollab` map absent (no page opted in)
|
|
46
|
+
* - the URL tail doesn't match any registered page slug (resource
|
|
47
|
+
* list/edit/view pages, dashboard, theme editor, etc.)
|
|
48
|
+
*
|
|
49
|
+
* Mounted inside `AppShell` around the page content area, beside
|
|
50
|
+
* `RecordWrapperGate`. The two gates are mutually exclusive in
|
|
51
|
+
* practice — record routes have 3+ segments ending in /edit or /view,
|
|
52
|
+
* custom-page routes are 1-2 segments matching a registered page slug.
|
|
53
|
+
*/
|
|
54
|
+
export function CustomPageWrapperGate({ currentPath, basePath, pageCollab, children }: CustomPageWrapperGateProps) {
|
|
55
|
+
const Wrapper = getCustomPageWrapper()
|
|
56
|
+
if (!Wrapper || !currentPath || !pageCollab) return <>{children}</>
|
|
57
|
+
|
|
58
|
+
const slug = pageSlugFromUrl(currentPath, basePath)
|
|
59
|
+
if (!slug) return <>{children}</>
|
|
60
|
+
|
|
61
|
+
const cfg = pageCollab[slug]
|
|
62
|
+
if (!cfg) return <>{children}</>
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Wrapper pageSlug={slug} room={cfg.room} presence={cfg.presence}>
|
|
66
|
+
{children}
|
|
67
|
+
</Wrapper>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Props the custom-page wrapper receives from `CustomPageWrapperGate`
|
|
5
|
+
* when the current URL resolves to an opted-in custom page (a `Page`
|
|
6
|
+
* subclass with `static collab = { room: '…' }`).
|
|
7
|
+
*
|
|
8
|
+
* The wrapper owns whatever page-scoped context the plugin provides —
|
|
9
|
+
* `@pilotiq-pro/collab` mounts a collab room here so every collab
|
|
10
|
+
* field inside the page tree shares one Y.Doc + WS connection. Other
|
|
11
|
+
* plugins could mount per-page presence, audit logging, etc.
|
|
12
|
+
*
|
|
13
|
+
* `pageSlug` is the gate's URL slug (cluster-prefixed for clustered
|
|
14
|
+
* pages). `room` is the literal `room` value the page declared on
|
|
15
|
+
* `static collab` — opaque to pilotiq; the plugin is free to namespace
|
|
16
|
+
* it internally before opening the WS.
|
|
17
|
+
*/
|
|
18
|
+
export interface CustomPageWrapperProps {
|
|
19
|
+
pageSlug: string
|
|
20
|
+
room: string
|
|
21
|
+
presence: boolean
|
|
22
|
+
children: ReactNode
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let _component: ComponentType<CustomPageWrapperProps> | null = null
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register a component that wraps the page tree on every opted-in
|
|
29
|
+
* custom-page route. Called once at boot by a plugin (e.g.
|
|
30
|
+
* `@pilotiq-pro/collab`). No-op when no plugin registers —
|
|
31
|
+
* `CustomPageWrapperGate` passes through unchanged.
|
|
32
|
+
*/
|
|
33
|
+
export function registerCustomPageWrapper(C: ComponentType<CustomPageWrapperProps>): void {
|
|
34
|
+
_component = C
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Returns the registered wrapper component, or `null`. */
|
|
38
|
+
export function getCustomPageWrapper(): ComponentType<CustomPageWrapperProps> | null {
|
|
39
|
+
return _component
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Test-only — drops any registered wrapper so tests stay isolated. */
|
|
43
|
+
export function _resetCustomPageWrapper(): void {
|
|
44
|
+
_component = null
|
|
45
|
+
}
|
|
@@ -101,6 +101,18 @@ export interface FormCollabBinding {
|
|
|
101
101
|
* renderer's optimistic update converge).
|
|
102
102
|
*/
|
|
103
103
|
subscribeRows?(arrayName: string, fn: (event: RowsEvent) => void): () => void
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Snapshot the current row id order for an array. Unlike `subscribeRows`,
|
|
107
|
+
* which only fires on delta events, this returns whatever is in the CRDT
|
|
108
|
+
* right now — used by the renderer's mount-time reconciler to detect
|
|
109
|
+
* orphaned rows (CRDT carries a UUID forward after the parent save
|
|
110
|
+
* switched the row's `__id` to a DB PK; see
|
|
111
|
+
* `docs/plans/repeater-relationship-pk-switch.md` Phase A).
|
|
112
|
+
*
|
|
113
|
+
* Empty array when the binding has no state for `arrayName` yet.
|
|
114
|
+
*/
|
|
115
|
+
getRowOrder?(arrayName: string): string[]
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
/**
|
|
@@ -134,6 +146,11 @@ export interface RowBindingApi {
|
|
|
134
146
|
* rowId presence in its current state. Optional on the underlying
|
|
135
147
|
* contract — `null` when the binding lacks `subscribeRows`. */
|
|
136
148
|
subscribe(fn: (event: RowsEvent) => void): () => void
|
|
149
|
+
/** Snapshot the array's current row id order. Empty array when the
|
|
150
|
+
* binding has no state yet OR when the underlying binding doesn't
|
|
151
|
+
* implement `getRowOrder` (in which case mount-time reconciliation
|
|
152
|
+
* no-ops). */
|
|
153
|
+
current(): string[]
|
|
137
154
|
}
|
|
138
155
|
|
|
139
156
|
/**
|
|
@@ -265,7 +265,7 @@ export function FormStateProvider({
|
|
|
265
265
|
// binding that has addRow but not reorderRows) skip the stash — the
|
|
266
266
|
// contract says all three or nothing.
|
|
267
267
|
if (binding.addRow && binding.removeRow && binding.reorderRows) {
|
|
268
|
-
const { addRow, removeRow, reorderRows, subscribeRows } = binding
|
|
268
|
+
const { addRow, removeRow, reorderRows, subscribeRows, getRowOrder } = binding
|
|
269
269
|
const arrayNames = collectRowArrayFieldNames(formMetaRef.current)
|
|
270
270
|
if (arrayNames.length > 0) {
|
|
271
271
|
const rowStash = new Map<string, RowBindingApi>()
|
|
@@ -281,6 +281,13 @@ export function FormStateProvider({
|
|
|
281
281
|
subscribe: subscribeRows
|
|
282
282
|
? (fn) => subscribeRows.call(binding, arrayName, fn)
|
|
283
283
|
: () => () => {},
|
|
284
|
+
// `getRowOrder` is optional — bindings that ship row CRUD
|
|
285
|
+
// without a snapshot read (test stubs, older plugins) get a
|
|
286
|
+
// `[]` substitute. The renderer's reconciler treats empty as
|
|
287
|
+
// "no orphans known" and no-ops, which is the safest fallback.
|
|
288
|
+
current: getRowOrder
|
|
289
|
+
? () => getRowOrder.call(binding, arrayName)
|
|
290
|
+
: () => [],
|
|
284
291
|
})
|
|
285
292
|
}
|
|
286
293
|
setRowBindings(rowStash)
|
|
@@ -9,6 +9,7 @@ import { RowCoordsContext } from '../RowCoordsContext.js'
|
|
|
9
9
|
import { useIconFor } from '../icon-context.js'
|
|
10
10
|
import { reorderRows, ExtraActionStrip, buildGridContainer } from './RepeaterInput.js'
|
|
11
11
|
import { syncRowGates } from './syncRowGates.js'
|
|
12
|
+
import { consumeReconcileFlag, computeReconcilePlan } from './repeaterReconcile.js'
|
|
12
13
|
import type { RowButtonsMeta } from '../../fields/RowButton.js'
|
|
13
14
|
import {
|
|
14
15
|
RowChromeIconButton,
|
|
@@ -252,6 +253,31 @@ export function BuilderInput({
|
|
|
252
253
|
})
|
|
253
254
|
})
|
|
254
255
|
}, [rowBinding, blocksByName, meta.blocks])
|
|
256
|
+
|
|
257
|
+
// Phase A reconciliation for `Builder.relationship` PK-switch — mirrors
|
|
258
|
+
// the effect in `RepeaterInput`. See its comment + the plan doc:
|
|
259
|
+
// `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`.
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (!rowBinding) return
|
|
262
|
+
if (!consumeReconcileFlag(formId)) return
|
|
263
|
+
const timer = setTimeout(() => {
|
|
264
|
+
const plan = computeReconcilePlan({
|
|
265
|
+
current: rowBinding.current(),
|
|
266
|
+
authoritative: initialRows.map(r => r.id),
|
|
267
|
+
})
|
|
268
|
+
for (const id of plan.toRemove) rowBinding.remove(id)
|
|
269
|
+
// For Builder, the row carries a block `type` discriminator; seed
|
|
270
|
+
// it on the add path so peers' picker dropdowns see the right
|
|
271
|
+
// block (matches the existing add path in `addBlock`). The block
|
|
272
|
+
// type comes from initialRows when the row is server-rendered.
|
|
273
|
+
for (const id of plan.toAdd) {
|
|
274
|
+
const row = initialRows.find(r => r.id === id)
|
|
275
|
+
rowBinding.add(id, row?.type ? { type: row.type } : {})
|
|
276
|
+
}
|
|
277
|
+
}, 1500)
|
|
278
|
+
return () => clearTimeout(timer)
|
|
279
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
280
|
+
}, [rowBinding, formId])
|
|
255
281
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() =>
|
|
256
282
|
accordion ? {} : initSeedCollapsed(initialRows, formId, name, defaultCollapsed, collapsible),
|
|
257
283
|
)
|
|
@@ -361,26 +387,23 @@ export function BuilderInput({
|
|
|
361
387
|
}
|
|
362
388
|
|
|
363
389
|
const moveRow = (id: string, dir: -1 | 1): void => {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
let
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
return next
|
|
382
|
-
})
|
|
383
|
-
if (newOrder !== null) rowBinding?.reorder(newOrder)
|
|
390
|
+
const idx = rows.findIndex(r => r.id === id)
|
|
391
|
+
if (idx < 0) return
|
|
392
|
+
let next: RowState[]
|
|
393
|
+
if (dir === -1) {
|
|
394
|
+
let target = idx - 1
|
|
395
|
+
while (target >= 0 && rows[target]?.hidden) target--
|
|
396
|
+
if (target < 0) return
|
|
397
|
+
next = reorderRows(rows, idx, target)
|
|
398
|
+
} else {
|
|
399
|
+
let target = idx + 1
|
|
400
|
+
while (target < rows.length && rows[target]?.hidden) target++
|
|
401
|
+
if (target >= rows.length) return
|
|
402
|
+
next = reorderRows(rows, idx, target + 1)
|
|
403
|
+
}
|
|
404
|
+
if (next === rows) return
|
|
405
|
+
setRows(next)
|
|
406
|
+
rowBinding?.reorder(next.map(r => r.id))
|
|
384
407
|
}
|
|
385
408
|
|
|
386
409
|
// ── DnD state (skipped when buttonsOnly) ────────────────
|
|
@@ -394,15 +417,16 @@ export function BuilderInput({
|
|
|
394
417
|
} = useRowReorderDnd({
|
|
395
418
|
enabled: dndEnabled,
|
|
396
419
|
onDrop: (fromId, at) => {
|
|
397
|
-
|
|
398
|
-
setRows
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
420
|
+
// See RepeaterInput's matching onDrop comment — closure-mutation
|
|
421
|
+
// through setRows's updater is unreliable when other state updates
|
|
422
|
+
// are batched (useRowReorderDnd nulls dragId/dropAt right before
|
|
423
|
+
// calling this).
|
|
424
|
+
const fromIdx = rows.findIndex(r => r.id === fromId)
|
|
425
|
+
if (fromIdx < 0) return
|
|
426
|
+
const next = reorderRows(rows, fromIdx, at)
|
|
427
|
+
if (next === rows) return
|
|
428
|
+
setRows(next)
|
|
429
|
+
rowBinding?.reorder(next.map(r => r.id))
|
|
406
430
|
},
|
|
407
431
|
})
|
|
408
432
|
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
DEFAULT_DELETE,
|
|
22
22
|
} from './rowChromeButton.js'
|
|
23
23
|
import { syncRowGates } from './syncRowGates.js'
|
|
24
|
+
import { consumeReconcileFlag, computeReconcilePlan } from './repeaterReconcile.js'
|
|
24
25
|
import {
|
|
25
26
|
generateRowId, makeAccordionStorage, makeCollapsedStorage,
|
|
26
27
|
} from './rowState.js'
|
|
@@ -304,6 +305,37 @@ export function RepeaterInput({
|
|
|
304
305
|
})
|
|
305
306
|
})
|
|
306
307
|
}, [rowBinding, meta.template])
|
|
308
|
+
|
|
309
|
+
// Phase A reconciliation for `Repeater.relationship` PK-switch — when
|
|
310
|
+
// the surrounding form just submitted in this tab AND we're inside a
|
|
311
|
+
// collab room with a row binding, snapshot the CRDT order after a
|
|
312
|
+
// short settle (long enough for WS sync to deliver any persisted
|
|
313
|
+
// state) and reconcile against `initialRows`. Drops orphan UUIDs
|
|
314
|
+
// whose rows just persisted under a fresh DB PK; idempotent + no-op
|
|
315
|
+
// for non-relationship Repeaters where `__id` stays UUID across
|
|
316
|
+
// save+reload. Plan:
|
|
317
|
+
// `pilotiq-pro/docs/plans/repeater-relationship-pk-switch.md`.
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
if (!rowBinding) return
|
|
320
|
+
if (!consumeReconcileFlag(formId)) return
|
|
321
|
+
// Give WS sync time to deliver any persisted rows before reading
|
|
322
|
+
// current(). 1500ms is conservative; typical sync settles in <300ms.
|
|
323
|
+
// The reconciler is one-shot per submit, so we accept the brief
|
|
324
|
+
// visual flicker over a tighter timer that might fire pre-sync.
|
|
325
|
+
const timer = setTimeout(() => {
|
|
326
|
+
const plan = computeReconcilePlan({
|
|
327
|
+
current: rowBinding.current(),
|
|
328
|
+
authoritative: initialRows.map(r => r.id),
|
|
329
|
+
})
|
|
330
|
+
for (const id of plan.toRemove) rowBinding.remove(id)
|
|
331
|
+
for (const id of plan.toAdd) rowBinding.add(id, {})
|
|
332
|
+
}, 1500)
|
|
333
|
+
return () => clearTimeout(timer)
|
|
334
|
+
// initialRows is a stable useMemo([]) ref so it's safe to omit. We
|
|
335
|
+
// intentionally key only on rowBinding + formId — the reconciler is
|
|
336
|
+
// tied to the submit lifecycle, not to row-state changes.
|
|
337
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
338
|
+
}, [rowBinding, formId])
|
|
307
339
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() =>
|
|
308
340
|
accordion ? {} : initSeedCollapsed(initialRows, formId, name, defaultCollapsed, collapsible),
|
|
309
341
|
)
|
|
@@ -394,29 +426,26 @@ export function RepeaterInput({
|
|
|
394
426
|
}
|
|
395
427
|
|
|
396
428
|
const moveRow = (id: string, dir: -1 | 1): void => {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
let
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
return next
|
|
418
|
-
})
|
|
419
|
-
if (newOrder !== null) rowBinding?.reorder(newOrder)
|
|
429
|
+
const idx = rows.findIndex(r => r.id === id)
|
|
430
|
+
if (idx < 0) return
|
|
431
|
+
// Skip past hidden neighbours so reorder operates between visible
|
|
432
|
+
// rows. Hidden rows hold their absolute slot — the visible row hops
|
|
433
|
+
// over them.
|
|
434
|
+
let next: RowState[]
|
|
435
|
+
if (dir === -1) {
|
|
436
|
+
let target = idx - 1
|
|
437
|
+
while (target >= 0 && rows[target]?.hidden) target--
|
|
438
|
+
if (target < 0) return
|
|
439
|
+
next = reorderRows(rows, idx, target)
|
|
440
|
+
} else {
|
|
441
|
+
let target = idx + 1
|
|
442
|
+
while (target < rows.length && rows[target]?.hidden) target++
|
|
443
|
+
if (target >= rows.length) return
|
|
444
|
+
next = reorderRows(rows, idx, target + 1)
|
|
445
|
+
}
|
|
446
|
+
if (next === rows) return
|
|
447
|
+
setRows(next)
|
|
448
|
+
rowBinding?.reorder(next.map(r => r.id))
|
|
420
449
|
}
|
|
421
450
|
|
|
422
451
|
// ── DnD state ───────────────────────────────────────────
|
|
@@ -429,15 +458,20 @@ export function RepeaterInput({
|
|
|
429
458
|
} = useRowReorderDnd({
|
|
430
459
|
enabled: reorderable && !disabled,
|
|
431
460
|
onDrop: (fromId, at) => {
|
|
432
|
-
|
|
433
|
-
setRows(
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
461
|
+
// Compute next from the current `rows` directly. The previous
|
|
462
|
+
// setRows(updater) + closure-mutation pattern relied on React
|
|
463
|
+
// running the updater synchronously inside setState — which only
|
|
464
|
+
// happens when no other update is queued. `useRowReorderDnd`'s
|
|
465
|
+
// handleDrop sets dragId/dropAt to null right before calling
|
|
466
|
+
// this callback, so the updater runs in commit phase and the
|
|
467
|
+
// outer `newOrder` stayed null past the `if` check, silently
|
|
468
|
+
// skipping the rowBinding.reorder broadcast.
|
|
469
|
+
const fromIdx = rows.findIndex(r => r.id === fromId)
|
|
470
|
+
if (fromIdx < 0) return
|
|
471
|
+
const next = reorderRows(rows, fromIdx, at)
|
|
472
|
+
if (next === rows) return
|
|
473
|
+
setRows(next)
|
|
474
|
+
rowBinding?.reorder(next.map(r => r.id))
|
|
441
475
|
},
|
|
442
476
|
})
|
|
443
477
|
|