@pilotiq/pilotiq 0.8.2 → 0.9.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 +165 -0
- package/dist/Resource.d.ts +39 -0
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +30 -0
- package/dist/Resource.js.map +1 -1
- package/dist/pageData/navigation.d.ts +17 -1
- package/dist/pageData/navigation.d.ts.map +1 -1
- package/dist/pageData/navigation.js +14 -0
- package/dist/pageData/navigation.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 +1 -1
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/FormCollabBindingRegistry.d.ts +71 -1
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
- package/dist/react/FormCollabBindingRegistry.js.map +1 -1
- package/dist/react/FormStateContext.d.ts +17 -0
- package/dist/react/FormStateContext.d.ts.map +1 -1
- package/dist/react/FormStateContext.js +44 -3
- package/dist/react/FormStateContext.js.map +1 -1
- package/dist/react/RecordWrapperGate.d.ts +19 -6
- package/dist/react/RecordWrapperGate.d.ts.map +1 -1
- package/dist/react/RecordWrapperGate.js +18 -8
- package/dist/react/RecordWrapperGate.js.map +1 -1
- package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
- package/dist/react/fields/MarkdownInput.js +105 -3
- package/dist/react/fields/MarkdownInput.js.map +1 -1
- package/dist/react/fields/TextLikeInput.d.ts +10 -0
- package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
- package/dist/react/fields/TextLikeInput.js +179 -0
- package/dist/react/fields/TextLikeInput.js.map +1 -1
- package/dist/react/fields/textDelta.d.ts +44 -0
- package/dist/react/fields/textDelta.d.ts.map +1 -0
- package/dist/react/fields/textDelta.js +80 -0
- package/dist/react/fields/textDelta.js.map +1 -0
- 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/dist/react/parseRecordEditUrl.d.ts +33 -9
- package/dist/react/parseRecordEditUrl.d.ts.map +1 -1
- package/dist/react/parseRecordEditUrl.js +40 -2
- package/dist/react/parseRecordEditUrl.js.map +1 -1
- package/package.json +1 -1
- package/src/Resource.test.ts +44 -0
- package/src/Resource.ts +58 -0
- package/src/pageData/navigation.ts +32 -1
- package/src/pageData.test.ts +36 -0
- package/src/react/AppShell.tsx +6 -1
- package/src/react/FormCollabBindingRegistry.ts +63 -1
- package/src/react/FormStateContext.tsx +62 -3
- package/src/react/RecordWrapperGate.tsx +26 -8
- package/src/react/fields/MarkdownInput.tsx +100 -3
- package/src/react/fields/TextLikeInput.tsx +203 -1
- package/src/react/fields/textDelta.test.ts +141 -0
- package/src/react/fields/textDelta.ts +86 -0
- package/src/react/index.ts +9 -1
- package/src/react/parseRecordEditUrl.test.ts +48 -1
- package/src/react/parseRecordEditUrl.ts +52 -13
|
@@ -1,4 +1,34 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* URL → record-page identity parser. Used by `RecordWrapperGate` (and any
|
|
3
|
+
* plugin that wants to reason about record-bound URLs) to decide whether
|
|
4
|
+
* the current page is a record-edit or record-view route.
|
|
5
|
+
*
|
|
6
|
+
* A URL matches when:
|
|
7
|
+
* 1. it starts with the panel's `basePath`
|
|
8
|
+
* 2. after stripping the prefix it ends with `/edit` or `/view`
|
|
9
|
+
* 3. there are at least three remaining segments (resource slug,
|
|
10
|
+
* record id, terminal token)
|
|
11
|
+
*
|
|
12
|
+
* The `resourceSlug` is the slash-joined chain of every segment up to
|
|
13
|
+
* the record id — this gives clustered resources (`${base}/blog/articles/123/edit`)
|
|
14
|
+
* and nested-relation edits (`${base}/articles/123/comments/456/edit`)
|
|
15
|
+
* distinct slugs so two URLs that target different records always
|
|
16
|
+
* produce different room names downstream.
|
|
17
|
+
*
|
|
18
|
+
* `/admin/articles/123/edit` → { slug: 'articles', id: '123', role: 'edit' }
|
|
19
|
+
* `/admin/articles/123/view` → { slug: 'articles', id: '123', role: 'view' }
|
|
20
|
+
* `/admin/blog/articles/123/edit` → { slug: 'blog/articles', id: '123', role: 'edit' }
|
|
21
|
+
* `/admin/articles/123/comments/456/edit` → { slug: 'articles/123/comments', id: '456', role: 'edit' }
|
|
22
|
+
* `/admin/articles/123/comments` → null (no trailing /edit or /view)
|
|
23
|
+
* `/admin/articles/123/comments/create` → null (terminal token isn't edit|view)
|
|
24
|
+
* `/site/articles/123/edit` → null (basePath mismatch when basePath='/admin')
|
|
25
|
+
*/
|
|
26
|
+
const RECOGNIZED_ROLES = ['edit', 'view'];
|
|
27
|
+
/**
|
|
28
|
+
* Parse a pilotiq URL into a `{ slug, id, role }` identity. Returns
|
|
29
|
+
* `null` for any URL that isn't a record-edit or record-view page.
|
|
30
|
+
*/
|
|
31
|
+
export function parseRecordPageUrl(currentPath, basePath) {
|
|
2
32
|
if (!currentPath)
|
|
3
33
|
return null;
|
|
4
34
|
// Normalise — trailing slashes on the URL or trailing slashes on
|
|
@@ -11,7 +41,8 @@ export function parseRecordEditUrl(currentPath, basePath) {
|
|
|
11
41
|
const parts = tail.split('/').filter(Boolean);
|
|
12
42
|
if (parts.length < 3)
|
|
13
43
|
return null;
|
|
14
|
-
|
|
44
|
+
const terminal = parts[parts.length - 1];
|
|
45
|
+
if (!RECOGNIZED_ROLES.includes(terminal))
|
|
15
46
|
return null;
|
|
16
47
|
const recordId = parts[parts.length - 2];
|
|
17
48
|
const slugParts = parts.slice(0, parts.length - 2);
|
|
@@ -20,6 +51,13 @@ export function parseRecordEditUrl(currentPath, basePath) {
|
|
|
20
51
|
return {
|
|
21
52
|
resourceSlug: slugParts.join('/'),
|
|
22
53
|
recordId,
|
|
54
|
+
role: terminal,
|
|
23
55
|
};
|
|
24
56
|
}
|
|
57
|
+
export function parseRecordEditUrl(currentPath, basePath) {
|
|
58
|
+
const identity = parseRecordPageUrl(currentPath, basePath);
|
|
59
|
+
if (!identity || identity.role !== 'edit')
|
|
60
|
+
return null;
|
|
61
|
+
return { resourceSlug: identity.resourceSlug, recordId: identity.recordId };
|
|
62
|
+
}
|
|
25
63
|
//# sourceMappingURL=parseRecordEditUrl.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parseRecordEditUrl.js","sourceRoot":"","sources":["../../src/react/parseRecordEditUrl.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"parseRecordEditUrl.js","sourceRoot":"","sources":["../../src/react/parseRecordEditUrl.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAiBH,MAAM,gBAAgB,GAAkC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAExE;;;GAGG;AACH,MAAM,UAAU,kBAAkB,CAChC,WAAmB,EACnB,QAAmB;IAEnB,IAAI,CAAC,WAAW;QAAE,OAAO,IAAI,CAAA;IAC7B,iEAAiE;IACjE,2DAA2D;IAC3D,MAAM,WAAW,GAAG,WAAW,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;IACnD,MAAM,WAAW,GAAG,QAAQ,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;IAEhD,IAAI,WAAW,KAAK,EAAE,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,WAAW,CAAC;QAAE,OAAO,IAAI,CAAA;IAE3E,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;IACtE,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IAE7C,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,IAAI,CAAA;IACjC,MAAM,QAAQ,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAE,CAAA;IACzC,IAAI,CAAC,gBAAgB,CAAC,QAAQ,CAAC,QAA0B,CAAC;QAAE,OAAO,IAAI,CAAA;IAEvE,MAAM,QAAQ,GAAM,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAE,CAAA;IAC5C,MAAM,SAAS,GAAK,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;IACpD,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IAEvC,OAAO;QACL,YAAY,EAAE,SAAS,CAAC,IAAI,CAAC,GAAG,CAAC;QACjC,QAAQ;QACR,IAAI,EAAU,QAA0B;KACzC,CAAA;AACH,CAAC;AAWD,MAAM,UAAU,kBAAkB,CAChC,WAAmB,EACnB,QAAmB;IAEnB,MAAM,QAAQ,GAAG,kBAAkB,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAA;IAC1D,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,IAAI,KAAK,MAAM;QAAE,OAAO,IAAI,CAAA;IACtD,OAAO,EAAE,YAAY,EAAE,QAAQ,CAAC,YAAY,EAAE,QAAQ,EAAE,QAAQ,CAAC,QAAQ,EAAE,CAAA;AAC7E,CAAC"}
|
package/package.json
CHANGED
package/src/Resource.test.ts
CHANGED
|
@@ -237,4 +237,48 @@ describe('Resource (static API)', () => {
|
|
|
237
237
|
assert.equal(await R.canRestore(null, { ownedBy: 'other' }), false)
|
|
238
238
|
})
|
|
239
239
|
})
|
|
240
|
+
|
|
241
|
+
describe('collab opt-in', () => {
|
|
242
|
+
it('omitted static collab → getResolvedCollabConfig returns null', () => {
|
|
243
|
+
class R extends Resource {}
|
|
244
|
+
assert.equal(R.getResolvedCollabConfig(), null)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('static collab = false → null (explicit opt-out)', () => {
|
|
248
|
+
class R extends Resource {
|
|
249
|
+
static override collab = false as const
|
|
250
|
+
}
|
|
251
|
+
assert.equal(R.getResolvedCollabConfig(), null)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('static collab = true → defaults to { pages: [edit], presence: true }', () => {
|
|
255
|
+
class R extends Resource {
|
|
256
|
+
static override collab = true as const
|
|
257
|
+
}
|
|
258
|
+
assert.deepEqual(R.getResolvedCollabConfig(), {
|
|
259
|
+
pages: ['edit'],
|
|
260
|
+
presence: true,
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('object form merges with defaults', () => {
|
|
265
|
+
class R extends Resource {
|
|
266
|
+
static override collab = { pages: ['edit', 'view'] as const }
|
|
267
|
+
}
|
|
268
|
+
assert.deepEqual(R.getResolvedCollabConfig(), {
|
|
269
|
+
pages: ['edit', 'view'],
|
|
270
|
+
presence: true, // default preserved
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('object form can suppress presence', () => {
|
|
275
|
+
class R extends Resource {
|
|
276
|
+
static override collab = { presence: false }
|
|
277
|
+
}
|
|
278
|
+
assert.deepEqual(R.getResolvedCollabConfig(), {
|
|
279
|
+
pages: ['edit'], // default preserved
|
|
280
|
+
presence: false,
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
})
|
|
240
284
|
})
|
package/src/Resource.ts
CHANGED
|
@@ -50,6 +50,33 @@ export type NavigationBadgeColor =
|
|
|
50
50
|
export type NavigationBadgeHandler =
|
|
51
51
|
() => string | number | undefined | Promise<string | number | undefined>
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Per-resource collab configuration. Set via `static collab = true` (the
|
|
55
|
+
* 90% case — opts the edit page in with presence) or via the object form
|
|
56
|
+
* for finer control. Omitting `static collab` entirely keeps the resource
|
|
57
|
+
* collab-free even when the `@pilotiq-pro/collab` plugin is registered.
|
|
58
|
+
*
|
|
59
|
+
* pages — page roles where collab activates. `'edit'` syncs values +
|
|
60
|
+
* presence; `'view'` is presence-only (the page is read-only,
|
|
61
|
+
* value-sync would be moot). Defaults to `['edit']`.
|
|
62
|
+
* presence — when false, suppress the awareness layer (focus chips,
|
|
63
|
+
* cursor positions) while keeping value-sync. Defaults to true.
|
|
64
|
+
*
|
|
65
|
+
* Field-level `.collab(false)` always wins over this setting — opting the
|
|
66
|
+
* resource in then opting individual fields out is the supported shape.
|
|
67
|
+
*/
|
|
68
|
+
export interface ResourceCollabConfig {
|
|
69
|
+
pages: ReadonlyArray<'edit' | 'view'>
|
|
70
|
+
presence: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Raw shape accepted by `static collab` before normalization. `true` is a
|
|
74
|
+
* shorthand for `{ pages: ['edit'], presence: true }`. */
|
|
75
|
+
export type ResourceCollabInput = boolean | {
|
|
76
|
+
pages?: ReadonlyArray<'edit' | 'view'>
|
|
77
|
+
presence?: boolean
|
|
78
|
+
}
|
|
79
|
+
|
|
53
80
|
/**
|
|
54
81
|
* Abstract Resource base class. **All methods are static** — resources are
|
|
55
82
|
* registered by class, not by instance. Routes look up the class and call
|
|
@@ -297,6 +324,37 @@ export abstract class Resource {
|
|
|
297
324
|
return undefined
|
|
298
325
|
}
|
|
299
326
|
|
|
327
|
+
// ─── Realtime collab opt-in ────────────────────────────────
|
|
328
|
+
// Opt-in per resource. The `@pilotiq-pro/collab` plugin registers the
|
|
329
|
+
// global singletons (transport, Tiptap extension factory, RecordWrapper
|
|
330
|
+
// factory); resources with `static collab` unset stay collab-free even
|
|
331
|
+
// when the plugin is installed. Field-level `.collab(false)` overrides
|
|
332
|
+
// this setting per field.
|
|
333
|
+
|
|
334
|
+
/** Enable collab on this resource. `true` is the 90% case — shorthand
|
|
335
|
+
* for `{ pages: ['edit'], presence: true }`. Use the object form to
|
|
336
|
+
* narrow which page roles activate or to suppress presence. Default
|
|
337
|
+
* `false` (collab off — the WS room is never opened for this resource). */
|
|
338
|
+
static collab: ResourceCollabInput = false
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Normalize `static collab` into the canonical wire shape, or return
|
|
342
|
+
* `null` when the resource has opted out. Centralizes the `true` →
|
|
343
|
+
* defaults expansion and the `{ pages?: … }` merge.
|
|
344
|
+
*
|
|
345
|
+
* Result lands on `panelInfo().recordCollab[slug]`; the gate reads it
|
|
346
|
+
* to decide whether to mount the record wrapper.
|
|
347
|
+
*/
|
|
348
|
+
static getResolvedCollabConfig(): ResourceCollabConfig | null {
|
|
349
|
+
const raw = this.collab
|
|
350
|
+
if (raw === false || raw == null) return null
|
|
351
|
+
if (raw === true) return { pages: ['edit'], presence: true }
|
|
352
|
+
return {
|
|
353
|
+
pages: raw.pages ?? ['edit'],
|
|
354
|
+
presence: raw.presence ?? true,
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
300
358
|
// ─── Plan #10: authorization predicates ────────────────────
|
|
301
359
|
// All async, all default `true`. Routes call them with the resolved
|
|
302
360
|
// user (from `Pilotiq.user(fn)`); the renderer threads the same user
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Pilotiq, PilotiqConfig } from '../Pilotiq.js'
|
|
2
2
|
import type { Page } from '../Page.js'
|
|
3
|
-
import type { ResourceClass, NavigationBadgeColor } from '../Resource.js'
|
|
3
|
+
import type { ResourceClass, NavigationBadgeColor, ResourceCollabConfig } from '../Resource.js'
|
|
4
4
|
import type { GlobalClass } from '../Global.js'
|
|
5
5
|
import type { ClusterClass } from '../Cluster.js'
|
|
6
6
|
import { resourceBasePath, globalBasePath, pageBasePath } from '../clusterPaths.js'
|
|
@@ -194,6 +194,35 @@ export interface PanelInfoRoute {
|
|
|
194
194
|
url?: string
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Per-resource collab opt-in map, keyed by the URL slug
|
|
199
|
+
* `parseRecordPageUrl` produces. Non-clustered resource → `getSlug()`;
|
|
200
|
+
* clustered resource → `${cluster.getSlug()}/${R.getSlug()}`.
|
|
201
|
+
*
|
|
202
|
+
* `RecordWrapperGate` reads this map to decide whether the page tree
|
|
203
|
+
* needs the plugin-registered RecordWrapper (collab room, audit, …)
|
|
204
|
+
* mounted around the record view/edit content area.
|
|
205
|
+
*
|
|
206
|
+
* Nested-relation edit URLs (`/articles/123/comments/456/edit`) have a
|
|
207
|
+
* dynamic-id segment in the gate's URL slug and don't match here in v1.
|
|
208
|
+
* Collab on nested-relation edits is a follow-up — top-level resource
|
|
209
|
+
* edits are the common case and ship now.
|
|
210
|
+
*/
|
|
211
|
+
export type RecordCollabMap = Record<string, ResourceCollabConfig>
|
|
212
|
+
|
|
213
|
+
function resourceSlugForGate(R: ResourceClass): string {
|
|
214
|
+
return R.cluster ? `${R.cluster.getSlug()}/${R.getSlug()}` : R.getSlug()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildRecordCollabMap(cfg: Readonly<PilotiqConfig>): RecordCollabMap | undefined {
|
|
218
|
+
const map: RecordCollabMap = {}
|
|
219
|
+
for (const R of cfg.resources) {
|
|
220
|
+
const collab = R.getResolvedCollabConfig()
|
|
221
|
+
if (collab) map[resourceSlugForGate(R)] = collab
|
|
222
|
+
}
|
|
223
|
+
return Object.keys(map).length > 0 ? map : undefined
|
|
224
|
+
}
|
|
225
|
+
|
|
197
226
|
export async function panelInfo(
|
|
198
227
|
pilotiq: Pilotiq,
|
|
199
228
|
req?: unknown,
|
|
@@ -210,6 +239,7 @@ export async function panelInfo(
|
|
|
210
239
|
buildRightSidebarMeta(cfg, user),
|
|
211
240
|
])
|
|
212
241
|
const databaseNotifications = buildDatabaseNotificationsMeta(cfg, user)
|
|
242
|
+
const recordCollab = buildRecordCollabMap(cfg)
|
|
213
243
|
// AI suggestion mode — sparse: omit when 'auto' (the default) so the
|
|
214
244
|
// wire shape stays minimal for panels that don't opt into review mode.
|
|
215
245
|
// Plugin clients (e.g. @pilotiq-pro/ai's `AiClientToolBindings`) read
|
|
@@ -225,6 +255,7 @@ export async function panelInfo(
|
|
|
225
255
|
...(userMenu ? { userMenu } : {}),
|
|
226
256
|
...(databaseNotifications ? { databaseNotifications } : {}),
|
|
227
257
|
...(rightSidebar ? { rightSidebar } : {}),
|
|
258
|
+
...(recordCollab ? { recordCollab } : {}),
|
|
228
259
|
...(Object.keys(renderHooks).length > 0 ? { renderHooks } : {}),
|
|
229
260
|
...(aiSuggestionsMode !== 'auto' ? { aiSuggestionsMode } : {}),
|
|
230
261
|
}
|
package/src/pageData.test.ts
CHANGED
|
@@ -1202,3 +1202,39 @@ describe('tagRichTextMentionUrls — nested Repeater + Builder rows', () => {
|
|
|
1202
1202
|
assert.equal(inner.stamped, '/admin/_form/art/mentions')
|
|
1203
1203
|
})
|
|
1204
1204
|
})
|
|
1205
|
+
|
|
1206
|
+
describe('panelInfo — recordCollab map (resource collab opt-in)', () => {
|
|
1207
|
+
it('absent when no resource opts in', async () => {
|
|
1208
|
+
class Posts extends Resource { static override label = 'Posts' }
|
|
1209
|
+
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts]))
|
|
1210
|
+
assert.equal((info as { recordCollab?: unknown }).recordCollab, undefined)
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
it('emits an entry for each opted-in resource keyed by URL slug', async () => {
|
|
1214
|
+
class Posts extends Resource {
|
|
1215
|
+
static override label = 'Posts'
|
|
1216
|
+
static override collab = true as const
|
|
1217
|
+
}
|
|
1218
|
+
class Users extends Resource {
|
|
1219
|
+
static override label = 'Users'
|
|
1220
|
+
// No collab — should NOT appear in the map.
|
|
1221
|
+
}
|
|
1222
|
+
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts, Users]))
|
|
1223
|
+
const map = (info as { recordCollab?: Record<string, unknown> }).recordCollab
|
|
1224
|
+
assert.deepEqual(map, {
|
|
1225
|
+
posts: { pages: ['edit'], presence: true },
|
|
1226
|
+
})
|
|
1227
|
+
})
|
|
1228
|
+
|
|
1229
|
+
it('honors object form of static collab (pages + presence override defaults)', async () => {
|
|
1230
|
+
class Posts extends Resource {
|
|
1231
|
+
static override label = 'Posts'
|
|
1232
|
+
static override collab = { pages: ['edit', 'view'] as const, presence: false }
|
|
1233
|
+
}
|
|
1234
|
+
const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts]))
|
|
1235
|
+
const map = (info as { recordCollab?: Record<string, unknown> }).recordCollab
|
|
1236
|
+
assert.deepEqual(map, {
|
|
1237
|
+
posts: { pages: ['edit', 'view'], presence: false },
|
|
1238
|
+
})
|
|
1239
|
+
})
|
|
1240
|
+
})
|
package/src/react/AppShell.tsx
CHANGED
|
@@ -10,7 +10,7 @@ import type { RightPanelRegistry } from './right-panel-registry.js'
|
|
|
10
10
|
import { RightPanelRegistryProvider } from './right-panel-registry.js'
|
|
11
11
|
import { RightSidebarProvider, useRightSidebarOptional } from './RightSidebarContext.js'
|
|
12
12
|
import { RightSidebar } from './RightSidebar.js'
|
|
13
|
-
import { RecordWrapperGate } from './RecordWrapperGate.js'
|
|
13
|
+
import { RecordWrapperGate, type RecordCollabMap } from './RecordWrapperGate.js'
|
|
14
14
|
import { useIsMobile } from './hooks/use-mobile.js'
|
|
15
15
|
import type { NavItem, UserMenuMeta, DatabaseNotificationsMeta, RightSidebarMeta } from '../pageData.js'
|
|
16
16
|
import type { RenderHookMap } from '../RenderHook.js'
|
|
@@ -34,6 +34,10 @@ export interface AppShellProps {
|
|
|
34
34
|
* `panelInfo()` only ships this when at least one contribution
|
|
35
35
|
* was registered AND passed the auth gate AND is non-hidden. */
|
|
36
36
|
rightSidebar?: RightSidebarMeta
|
|
37
|
+
/** Per-resource collab opt-in map — read by `RecordWrapperGate` to
|
|
38
|
+
* decide whether to mount the plugin-registered RecordWrapper on
|
|
39
|
+
* a record edit/view URL. Absent when no resource opted in. */
|
|
40
|
+
recordCollab?: RecordCollabMap
|
|
37
41
|
/** Pre-resolved render-hook slots for the panel chrome (body /
|
|
38
42
|
* topbar / sidebar / user-menu / footer / head). Sparse map —
|
|
39
43
|
* slots with no registered entries are absent. Built by
|
|
@@ -130,6 +134,7 @@ export function AppShell({ layout = 'sidebar', notifications, componentRegistry,
|
|
|
130
134
|
<RecordWrapperGate
|
|
131
135
|
basePath={props.basePath}
|
|
132
136
|
{...(props.currentPath !== undefined ? { currentPath: props.currentPath } : {})}
|
|
137
|
+
{...(props.panel.recordCollab !== undefined ? { recordCollab: props.panel.recordCollab } : {})}
|
|
133
138
|
>
|
|
134
139
|
{props.children}
|
|
135
140
|
</RecordWrapperGate>
|
|
@@ -1,5 +1,44 @@
|
|
|
1
|
+
import type { ElementMeta } from '../schema/Element.js'
|
|
1
2
|
import type { CollabRoom } from './CollabRoomContext.js'
|
|
2
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
|
+
|
|
3
42
|
/**
|
|
4
43
|
* Binding contract that a collab plugin returns from
|
|
5
44
|
* `registerFormCollabBinding` — wraps a single form's value map in a
|
|
@@ -16,8 +55,14 @@ import type { CollabRoom } from './CollabRoomContext.js'
|
|
|
16
55
|
* - `subscribe(fn)` registers a listener that fires when REMOTE
|
|
17
56
|
* changes land; `fn(snapshot)` receives the full updated map.
|
|
18
57
|
* 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.
|
|
19
63
|
* - `destroy()` is called on unmount — gives the plugin a chance to
|
|
20
|
-
* remove its CRDT observer.
|
|
64
|
+
* remove its CRDT observer. Implementations are expected to cascade
|
|
65
|
+
* into every `TextBinding` they issued.
|
|
21
66
|
*
|
|
22
67
|
* `unknown` payloads keep pilotiq core Yjs-free; the binding owns its
|
|
23
68
|
* own type knowledge. Same posture as `CollabExtensionFactory`.
|
|
@@ -29,6 +74,12 @@ export interface FormCollabBinding {
|
|
|
29
74
|
set(name: string, value: unknown): void
|
|
30
75
|
/** Subscribe to remote changes. Returns an unsubscribe function. */
|
|
31
76
|
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
|
|
32
83
|
/** Cleanup hook called when the form unmounts. */
|
|
33
84
|
destroy(): void
|
|
34
85
|
}
|
|
@@ -51,6 +102,17 @@ export interface FormCollabBindingFactoryArgs {
|
|
|
51
102
|
* map already populated and skip.
|
|
52
103
|
*/
|
|
53
104
|
initial: Record<string, unknown>
|
|
105
|
+
/**
|
|
106
|
+
* Phase F.6 — initial form meta from the server. The binding walks
|
|
107
|
+
* this once at construction to decide which fields are text-shaped
|
|
108
|
+
* (`fieldType ∈ { text, textarea, email, slug, markdown }`) and
|
|
109
|
+
* which have opted out via `.collab(false)`. Text fields get a
|
|
110
|
+
* dedicated `Y.Text` and route through `getTextBinding`; non-text
|
|
111
|
+
* fields stay on the `Y.Map`. The meta is captured at mount; later
|
|
112
|
+
* structural changes from `live()` re-resolves aren't re-walked
|
|
113
|
+
* (rare in practice — dynamic field add/remove is an F-followup).
|
|
114
|
+
*/
|
|
115
|
+
formMeta: ElementMeta
|
|
54
116
|
}
|
|
55
117
|
|
|
56
118
|
export type FormCollabBindingFactory = (args: FormCollabBindingFactoryArgs) => FormCollabBinding
|
|
@@ -18,7 +18,11 @@ import {
|
|
|
18
18
|
import { runJsHandler } from './fieldJsHandler.js'
|
|
19
19
|
import { useToast } from './Toaster.js'
|
|
20
20
|
import { useCollabRoom } from './CollabRoomContext.js'
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
getFormCollabBinding,
|
|
23
|
+
type FormCollabBinding,
|
|
24
|
+
type TextBinding,
|
|
25
|
+
} from './FormCollabBindingRegistry.js'
|
|
22
26
|
|
|
23
27
|
export type FieldStatus = 'idle' | 'pending'
|
|
24
28
|
|
|
@@ -40,6 +44,13 @@ export interface FormStateApi {
|
|
|
40
44
|
formMeta: ElementMeta
|
|
41
45
|
inFlight: boolean
|
|
42
46
|
fieldStatus: (name: string) => FieldStatus
|
|
47
|
+
/** Phase F.6 — per-field text-CRDT handles stashed at collab-room mount.
|
|
48
|
+
* `null` outside a room or before the binding effect has populated the
|
|
49
|
+
* map. The text/non-text allowlist lives in the binding impl —
|
|
50
|
+
* `FormStateProvider` asks for every top-level field and only stashes
|
|
51
|
+
* non-null answers, so a `Map.get()` hit means the binding has opted
|
|
52
|
+
* this field into the character-level path. */
|
|
53
|
+
textBindings: ReadonlyMap<string, TextBinding> | null
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
const FormStateContext = createContext<FormStateApi | null>(null)
|
|
@@ -93,6 +104,15 @@ export interface UseFieldStateResult {
|
|
|
93
104
|
/** True while a live re-resolve POST is in flight for this field. */
|
|
94
105
|
pending: boolean
|
|
95
106
|
errors: string[]
|
|
107
|
+
/** Phase F.6 — character-level CRDT handle for text-shaped fields when
|
|
108
|
+
* a collab room is mounted up-tree AND the binding strategy applies
|
|
109
|
+
* (allowlist + `.collab() !== false`). Null in every other case —
|
|
110
|
+
* outside a `FormStateProvider`, outside a `<RecordCollabRoom>`, on
|
|
111
|
+
* non-text fields, on dotted-path inner-Repeater rows (deferred to
|
|
112
|
+
* F.5), and on text fields opted out via `.collab(false)`. Text input
|
|
113
|
+
* renderers branch on this: non-null → character-level path with
|
|
114
|
+
* `applyDelta + observe`; null → today's whole-string LWW path. */
|
|
115
|
+
textBinding: TextBinding | null
|
|
96
116
|
}
|
|
97
117
|
|
|
98
118
|
/** Per-field accessor. Inside a `FormStateProvider` it returns the controlled
|
|
@@ -108,6 +128,7 @@ export function useFieldState(name: string): UseFieldStateResult {
|
|
|
108
128
|
triggerLive: () => {},
|
|
109
129
|
pending: false,
|
|
110
130
|
errors: [],
|
|
131
|
+
textBinding: null,
|
|
111
132
|
}
|
|
112
133
|
}
|
|
113
134
|
// Dotted-path fields (inner Repeater rows) always render uncontrolled
|
|
@@ -120,6 +141,11 @@ export function useFieldState(name: string): UseFieldStateResult {
|
|
|
120
141
|
triggerLive: (valueOverride?: unknown) => ctx.triggerLive(name, valueOverride),
|
|
121
142
|
pending: ctx.fieldStatus(name) === 'pending',
|
|
122
143
|
errors: ctx.errors[name] ?? [],
|
|
144
|
+
// Phase F.6 — dotted-path inner-Repeater rows skipped in v1 (deferred
|
|
145
|
+
// to F.5 alongside Y.Array row identity). Outside a collab room or
|
|
146
|
+
// for non-text fields, the stash returns null and the renderer falls
|
|
147
|
+
// back to today's whole-string LWW path.
|
|
148
|
+
textBinding: dotted ? null : (ctx.textBindings?.get(name) ?? null),
|
|
123
149
|
}
|
|
124
150
|
}
|
|
125
151
|
|
|
@@ -170,6 +196,13 @@ export function FormStateProvider({
|
|
|
170
196
|
const [errors, setErrors] = useState<Record<string, string[]>>(initialErrors)
|
|
171
197
|
const [pendingNames, setPendingNames] = useState<Set<string>>(() => new Set())
|
|
172
198
|
const [inFlight, setInFlight] = useState(false)
|
|
199
|
+
// Phase F.6 — per-field text-CRDT stash. `null` until the collab effect
|
|
200
|
+
// populates it; stays `null` outside a collab room. Stored in state (not
|
|
201
|
+
// a ref) so consumers of `useFieldState` re-render once the bindings
|
|
202
|
+
// land. One extra render after collab-mount; acceptable since the
|
|
203
|
+
// existing `setValuesState` overlay below already triggers one when the
|
|
204
|
+
// room has pre-existing state.
|
|
205
|
+
const [textBindings, setTextBindings] = useState<ReadonlyMap<string, TextBinding> | null>(null)
|
|
173
206
|
|
|
174
207
|
const { notify } = useToast()
|
|
175
208
|
|
|
@@ -215,7 +248,12 @@ export function FormStateProvider({
|
|
|
215
248
|
useEffect(() => {
|
|
216
249
|
if (!collabRoom || !bindingFactory || !formId) return
|
|
217
250
|
|
|
218
|
-
const binding = bindingFactory({
|
|
251
|
+
const binding = bindingFactory({
|
|
252
|
+
room: collabRoom,
|
|
253
|
+
formId,
|
|
254
|
+
initial: valuesRef.current,
|
|
255
|
+
formMeta: formMetaRef.current,
|
|
256
|
+
})
|
|
219
257
|
bindingRef.current = binding
|
|
220
258
|
|
|
221
259
|
// Lift any state already in the room (subsequent joiners — first
|
|
@@ -228,6 +266,25 @@ export function FormStateProvider({
|
|
|
228
266
|
setValuesState((prev) => ({ ...prev, ...synced }))
|
|
229
267
|
}
|
|
230
268
|
|
|
269
|
+
// Phase F.6 — ask the binding for a `TextBinding` on every top-level
|
|
270
|
+
// field name. The text/non-text allowlist lives in the binding impl,
|
|
271
|
+
// not in core: the binding returns `null` for non-text fields and
|
|
272
|
+
// text fields opted out via `.collab(false)`. `getTextBinding` is
|
|
273
|
+
// optional on the contract — F1-era plugins that haven't implemented
|
|
274
|
+
// it short-circuit the whole stash and every text field stays on the
|
|
275
|
+
// LWW path. We stash only the non-null answers. Cleanup is owned by
|
|
276
|
+
// `binding.destroy()` (expected to cascade into every issued
|
|
277
|
+
// `TextBinding`).
|
|
278
|
+
if (binding.getTextBinding) {
|
|
279
|
+
const textStash = new Map<string, TextBinding>()
|
|
280
|
+
for (const fieldName of Object.keys(valuesRef.current)) {
|
|
281
|
+
if (fieldName.includes('.')) continue
|
|
282
|
+
const tb = binding.getTextBinding(fieldName)
|
|
283
|
+
if (tb) textStash.set(fieldName, tb)
|
|
284
|
+
}
|
|
285
|
+
if (textStash.size > 0) setTextBindings(textStash)
|
|
286
|
+
}
|
|
287
|
+
|
|
231
288
|
// Subscribe to remote changes. Local writes ALSO trigger this
|
|
232
289
|
// (Yjs observers fire on local transactions too) — the per-key
|
|
233
290
|
// Object.is short-circuit below collapses them into no-op renders.
|
|
@@ -249,6 +306,7 @@ export function FormStateProvider({
|
|
|
249
306
|
unsubscribe()
|
|
250
307
|
binding.destroy()
|
|
251
308
|
bindingRef.current = null
|
|
309
|
+
setTextBindings(null)
|
|
252
310
|
}
|
|
253
311
|
// `valuesRef.current` is intentionally read once at mount — initial
|
|
254
312
|
// values seed the binding; subsequent edits flow through `setValue`
|
|
@@ -479,7 +537,8 @@ export function FormStateProvider({
|
|
|
479
537
|
formMeta,
|
|
480
538
|
inFlight,
|
|
481
539
|
fieldStatus,
|
|
482
|
-
|
|
540
|
+
textBindings,
|
|
541
|
+
}), [values, setValue, triggerLive, errors, applyErrors, formMeta, inFlight, fieldStatus, textBindings])
|
|
483
542
|
|
|
484
543
|
return (
|
|
485
544
|
<FormStateContext.Provider value={api}>
|
|
@@ -1,37 +1,55 @@
|
|
|
1
1
|
import { type ReactNode } from 'react'
|
|
2
2
|
import { getRecordWrapper } from './RecordWrapperRegistry.js'
|
|
3
|
-
import {
|
|
3
|
+
import { parseRecordPageUrl } from './parseRecordEditUrl.js'
|
|
4
|
+
import type { ResourceCollabConfig } from '../Resource.js'
|
|
5
|
+
|
|
6
|
+
/** Per-resource collab opt-in keyed by URL slug (`R.getSlug()` for
|
|
7
|
+
* non-clustered, `${cluster.slug}/${R.getSlug()}` for clustered). Built
|
|
8
|
+
* server-side by `panelInfo()` as `recordCollab`. */
|
|
9
|
+
export type RecordCollabMap = Record<string, ResourceCollabConfig>
|
|
4
10
|
|
|
5
11
|
export interface RecordWrapperGateProps {
|
|
6
12
|
currentPath?: string
|
|
7
13
|
basePath: string
|
|
14
|
+
/** Resource opt-in map. Absent means no resource opted in (or the
|
|
15
|
+
* panel has no resources) — gate always passes through. */
|
|
16
|
+
recordCollab?: RecordCollabMap
|
|
8
17
|
children: ReactNode
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
/**
|
|
12
21
|
* Conditionally wraps the page tree with the plugin-registered
|
|
13
|
-
* `RecordWrapper` when the current URL resolves to a record-bound
|
|
14
|
-
*
|
|
22
|
+
* `RecordWrapper` when the current URL resolves to a record-bound page
|
|
23
|
+
* AND the underlying resource has opted into collab on that page role.
|
|
24
|
+
* Pass-through in every other case:
|
|
15
25
|
*
|
|
16
26
|
* - no plugin registered a wrapper (`getRecordWrapper() === null`)
|
|
17
|
-
* - the URL isn't a record
|
|
27
|
+
* - the URL isn't a record edit/view page
|
|
18
28
|
* - `currentPath` not yet known on the very first SSR render
|
|
29
|
+
* - the resource has not opted in via `static collab` (or has opted in
|
|
30
|
+
* but excluded the current page role)
|
|
19
31
|
*
|
|
20
32
|
* Mounted once inside `AppShell` around the page content area so
|
|
21
33
|
* record-scoped plugins (collab room, audit trail, …) get one
|
|
22
34
|
* lifetimed mount per record-view-or-edit without each plugin having
|
|
23
35
|
* to thread URL parsing into its own provider.
|
|
24
36
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
37
|
+
* v1 caveat: nested-relation edit URLs (`/articles/:parentId/comments/:childId/edit`)
|
|
38
|
+
* have a dynamic-id segment baked into the URL slug, so they don't match
|
|
39
|
+
* the resource-keyed `recordCollab` map and fall through to no-collab.
|
|
40
|
+
* Collab on nested-relation edits is a follow-up.
|
|
27
41
|
*/
|
|
28
|
-
export function RecordWrapperGate({ currentPath, basePath, children }: RecordWrapperGateProps) {
|
|
42
|
+
export function RecordWrapperGate({ currentPath, basePath, recordCollab, children }: RecordWrapperGateProps) {
|
|
29
43
|
const Wrapper = getRecordWrapper()
|
|
30
44
|
if (!Wrapper || !currentPath) return <>{children}</>
|
|
31
45
|
|
|
32
|
-
const identity =
|
|
46
|
+
const identity = parseRecordPageUrl(currentPath, basePath)
|
|
33
47
|
if (!identity) return <>{children}</>
|
|
34
48
|
|
|
49
|
+
const cfg = recordCollab?.[identity.resourceSlug]
|
|
50
|
+
if (!cfg) return <>{children}</>
|
|
51
|
+
if (!cfg.pages.includes(identity.role)) return <>{children}</>
|
|
52
|
+
|
|
35
53
|
return (
|
|
36
54
|
<Wrapper resourceSlug={identity.resourceSlug} recordId={identity.recordId}>
|
|
37
55
|
{children}
|