@kernhq/module-quire 0.10.4 → 0.10.5
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/package.json +1 -1
- package/src/client/mock.ts +339 -2
package/package.json
CHANGED
package/src/client/mock.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
Comment,
|
|
3
|
+
CommentAnchor,
|
|
4
|
+
CommentThread,
|
|
2
5
|
Database,
|
|
3
6
|
DatabaseRef,
|
|
4
7
|
Row as DatabaseRow,
|
|
5
8
|
Page,
|
|
6
9
|
PageNode,
|
|
10
|
+
PageVersion,
|
|
7
11
|
Property,
|
|
8
12
|
PropertyConfig,
|
|
9
13
|
PropertyType,
|
|
@@ -113,8 +117,13 @@ export function createMockQuireApi() {
|
|
|
113
117
|
})
|
|
114
118
|
|
|
115
119
|
const pages: Row[] = [
|
|
116
|
-
page(101, uid(1), 'Welcome', 'a'),
|
|
117
|
-
page
|
|
120
|
+
page(101, uid(1), 'Welcome', 'a', null, { publishedVersionId: uid(152) }),
|
|
121
|
+
// A page with a draft readers cannot see yet, so the banner above the body is reachable. The
|
|
122
|
+
// server only ever sets this on a page that has been published once, and neither does this.
|
|
123
|
+
page(102, uid(1), 'Working here', 'b', null, {
|
|
124
|
+
publishedVersionId: uid(153),
|
|
125
|
+
hasUnpublishedChanges: true,
|
|
126
|
+
}),
|
|
118
127
|
page(103, uid(1), 'Your first week', 'ba', 102),
|
|
119
128
|
page(104, uid(1), 'Time off', 'bb', 102),
|
|
120
129
|
page(105, uid(1), 'Expenses', 'c', null, { kind: 'live' }),
|
|
@@ -237,6 +246,138 @@ export function createMockQuireApi() {
|
|
|
237
246
|
pages.push(row)
|
|
238
247
|
}
|
|
239
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The people the app's own mock signs you in as.
|
|
251
|
+
*
|
|
252
|
+
* Written out rather than derived: this module cannot see the shell's mock, and a comment with
|
|
253
|
+
* nobody's id on it loses the delete control only its author is offered — so the margin would
|
|
254
|
+
* look complete and be missing the one action that belongs to you.
|
|
255
|
+
*/
|
|
256
|
+
const ME = '01920000-0000-7000-8000-000000000001'
|
|
257
|
+
const COLLEAGUE = '01920000-0000-7000-8000-000000000002'
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* What the pages used to say, and what people have asked about them.
|
|
261
|
+
*
|
|
262
|
+
* Seeded rather than left empty. Version history and the comment margin are two of the three
|
|
263
|
+
* things a page screen is for, and until these existed the demo interface answered the history
|
|
264
|
+
* sheet with "The history could not be loaded" and never drew a margin at all — in exactly the
|
|
265
|
+
* environment used for demos and end-to-end tests. Two pages differ on purpose, so a page with a
|
|
266
|
+
* margin and a page without one are both reachable.
|
|
267
|
+
*/
|
|
268
|
+
const version = (
|
|
269
|
+
n: number,
|
|
270
|
+
pageId: string,
|
|
271
|
+
kind: PageVersion['kind'],
|
|
272
|
+
label: string | null,
|
|
273
|
+
preview: string,
|
|
274
|
+
msAgo: number,
|
|
275
|
+
authorId: string,
|
|
276
|
+
): PageVersion => ({
|
|
277
|
+
id: uid(n),
|
|
278
|
+
workspaceId: '' as PageVersion['workspaceId'],
|
|
279
|
+
pageId,
|
|
280
|
+
kind,
|
|
281
|
+
label,
|
|
282
|
+
preview,
|
|
283
|
+
// The server reports the length of the encoded document; the order of magnitude is all any
|
|
284
|
+
// screen does with it.
|
|
285
|
+
size: preview.length * 4,
|
|
286
|
+
authorId: authorId as PageVersion['authorId'],
|
|
287
|
+
createdAt: iso(msAgo),
|
|
288
|
+
// Which version readers are served is a property of the page, so it is worked out on the way
|
|
289
|
+
// out rather than stored here twice and left to disagree with itself.
|
|
290
|
+
published: false,
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
const versions: PageVersion[] = [
|
|
294
|
+
version(150, uid(101), 'publish', 'The first handbook', 'Welcome to Northstar.', 9e7, ME),
|
|
295
|
+
version(
|
|
296
|
+
151,
|
|
297
|
+
uid(101),
|
|
298
|
+
'auto',
|
|
299
|
+
null,
|
|
300
|
+
'Welcome to Northstar. We are a small team and we write things down.',
|
|
301
|
+
108e5,
|
|
302
|
+
COLLEAGUE,
|
|
303
|
+
),
|
|
304
|
+
version(
|
|
305
|
+
152,
|
|
306
|
+
uid(101),
|
|
307
|
+
'publish',
|
|
308
|
+
null,
|
|
309
|
+
'Welcome to Northstar. We are a small team and we write things down, so that nobody has to ask the same question twice.',
|
|
310
|
+
72e5,
|
|
311
|
+
ME,
|
|
312
|
+
),
|
|
313
|
+
version(153, uid(102), 'publish', null, 'How this team works, in one page.', 108e5, ME),
|
|
314
|
+
version(
|
|
315
|
+
154,
|
|
316
|
+
uid(102),
|
|
317
|
+
'auto',
|
|
318
|
+
null,
|
|
319
|
+
'How this team works, in one page. Start with your first week.',
|
|
320
|
+
36e5,
|
|
321
|
+
COLLEAGUE,
|
|
322
|
+
),
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
const richDoc = (text: string): Record<string, unknown> => ({
|
|
326
|
+
type: 'doc',
|
|
327
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
/** The same dumb walk the server does: whatever the editor produced, minus everything but text. */
|
|
331
|
+
const flatten = (body: unknown): string => {
|
|
332
|
+
const out: string[] = []
|
|
333
|
+
const walk = (node: unknown): void => {
|
|
334
|
+
if (!node || typeof node !== 'object') return
|
|
335
|
+
const n = node as { text?: unknown; content?: unknown[] }
|
|
336
|
+
if (typeof n.text === 'string') out.push(n.text)
|
|
337
|
+
if (Array.isArray(n.content)) for (const child of n.content) walk(child)
|
|
338
|
+
}
|
|
339
|
+
walk(body)
|
|
340
|
+
return out.join(' ').replace(/\s+/g, ' ').trim()
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const comment = (
|
|
344
|
+
n: number,
|
|
345
|
+
pageId: string,
|
|
346
|
+
threadId: string,
|
|
347
|
+
parentId: string | null,
|
|
348
|
+
authorId: string,
|
|
349
|
+
text: string,
|
|
350
|
+
msAgo: number,
|
|
351
|
+
): Comment => ({
|
|
352
|
+
id: uid(n),
|
|
353
|
+
workspaceId: '' as Comment['workspaceId'],
|
|
354
|
+
pageId,
|
|
355
|
+
parentId,
|
|
356
|
+
threadId,
|
|
357
|
+
authorId: authorId as Comment['authorId'],
|
|
358
|
+
body: richDoc(text),
|
|
359
|
+
bodyText: text,
|
|
360
|
+
mentionIds: [],
|
|
361
|
+
/*
|
|
362
|
+
* No anchor, and none of these quote anything.
|
|
363
|
+
*
|
|
364
|
+
* An anchor is a pair of Yjs relative positions into a document that only exists behind the
|
|
365
|
+
* collab service, and there is no collab service here — a made-up one would point at nothing
|
|
366
|
+
* and the editor would draw a highlight over the wrong words, which is worse than no highlight.
|
|
367
|
+
*/
|
|
368
|
+
anchor: null,
|
|
369
|
+
quotedText: '',
|
|
370
|
+
resolvedAt: null,
|
|
371
|
+
resolvedBy: null,
|
|
372
|
+
editedAt: null,
|
|
373
|
+
createdAt: iso(msAgo),
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const comments: Comment[] = [
|
|
377
|
+
comment(160, uid(101), uid(160), null, COLLEAGUE, 'Should this mention the on-call rota?', 72e5),
|
|
378
|
+
comment(161, uid(101), uid(160), uid(160), ME, 'Good point — I will link to it from here.', 36e5),
|
|
379
|
+
]
|
|
380
|
+
|
|
240
381
|
let seq = 900
|
|
241
382
|
const nextId = () => uid(++seq)
|
|
242
383
|
const strip = ({ _order, ...p }: Row): Page => p
|
|
@@ -261,6 +402,43 @@ export function createMockQuireApi() {
|
|
|
261
402
|
|
|
262
403
|
const notFound = (what: string) => Object.assign(new Error(`${what} not found`), { code: 'NOT_FOUND' })
|
|
263
404
|
|
|
405
|
+
const theVersion = (id: string): PageVersion => {
|
|
406
|
+
const found = versions.find((v) => v.id === id)
|
|
407
|
+
if (!found) throw notFound('Version')
|
|
408
|
+
return found
|
|
409
|
+
}
|
|
410
|
+
const theComment = (id: string): Comment => {
|
|
411
|
+
const found = comments.find((c) => c.id === id)
|
|
412
|
+
if (!found) throw notFound('Comment')
|
|
413
|
+
return found
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Which version a reader is served, which the list and the sheet both have to agree about. */
|
|
417
|
+
const publishedOn = (pageId: string) => pages.find((p) => p.id === pageId)?.publishedVersionId ?? null
|
|
418
|
+
const asVersion = (v: PageVersion): PageVersion => ({ ...v, published: v.id === publishedOn(v.pageId) })
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Write down what the page says now, exactly where the server takes a version.
|
|
422
|
+
*
|
|
423
|
+
* There is no document behind this, so the newest version's prose stands in for the live one —
|
|
424
|
+
* enough that restoring writes a new row saying what it restored, which is the behaviour the
|
|
425
|
+
* history sheet is judged on.
|
|
426
|
+
*/
|
|
427
|
+
const capture = (pageId: string, kind: PageVersion['kind'], label: string | null, preview?: string) => {
|
|
428
|
+
const latest = versions.filter((v) => v.pageId === pageId).at(-1)
|
|
429
|
+
const taken = version(
|
|
430
|
+
++seq,
|
|
431
|
+
pageId,
|
|
432
|
+
kind,
|
|
433
|
+
label,
|
|
434
|
+
preview ?? latest?.preview ?? found(pageId).title,
|
|
435
|
+
0,
|
|
436
|
+
ME,
|
|
437
|
+
)
|
|
438
|
+
versions.push(taken)
|
|
439
|
+
return taken
|
|
440
|
+
}
|
|
441
|
+
|
|
264
442
|
const theDatabase = (id: string): Database => {
|
|
265
443
|
const db = databases.find((d) => d.id === id)
|
|
266
444
|
if (!db) throw notFound('Database')
|
|
@@ -563,6 +741,165 @@ export function createMockQuireApi() {
|
|
|
563
741
|
},
|
|
564
742
|
},
|
|
565
743
|
|
|
744
|
+
versions: {
|
|
745
|
+
list: async ({ pageId, limit = 50 }: { pageId: string; limit?: number }) => ({
|
|
746
|
+
// Newest first, and the ids sort because they are minted in order — the same thing the
|
|
747
|
+
// server gets from ordering on a uuidv7.
|
|
748
|
+
items: versions
|
|
749
|
+
.filter((v) => v.pageId === pageId)
|
|
750
|
+
.sort((a, b) => (a.id < b.id ? 1 : -1))
|
|
751
|
+
.slice(0, limit)
|
|
752
|
+
.map(asVersion),
|
|
753
|
+
nextCursor: null,
|
|
754
|
+
}),
|
|
755
|
+
|
|
756
|
+
get: async ({ versionId }: { versionId: string }) => {
|
|
757
|
+
const found = theVersion(versionId)
|
|
758
|
+
return {
|
|
759
|
+
...asVersion(found),
|
|
760
|
+
text: found.preview,
|
|
761
|
+
// The server renders the stored document; the escaping is the part worth keeping, since
|
|
762
|
+
// a screen hands this straight to a renderer.
|
|
763
|
+
html: `<p>${found.preview.replace(/&/g, '&').replace(/</g, '<')}</p>`,
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
|
|
767
|
+
create: async ({ pageId, label = null }: { pageId: string; label?: string | null }) =>
|
|
768
|
+
asVersion(capture(pageId, 'auto', label)),
|
|
769
|
+
|
|
770
|
+
restore: async ({ versionId }: { versionId: string }) => {
|
|
771
|
+
const wanted = theVersion(versionId)
|
|
772
|
+
// The state about to be replaced is captured first, so restoring is itself undoable — the
|
|
773
|
+
// reason the sheet offers it without a confirmation.
|
|
774
|
+
capture(wanted.pageId, 'auto', null)
|
|
775
|
+
const restored = capture(wanted.pageId, 'restore', wanted.label, wanted.preview)
|
|
776
|
+
touch(found(wanted.pageId))
|
|
777
|
+
return asVersion(restored)
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
|
|
781
|
+
comments: {
|
|
782
|
+
list: async ({
|
|
783
|
+
pageId,
|
|
784
|
+
includeResolved = false,
|
|
785
|
+
}: {
|
|
786
|
+
pageId: string
|
|
787
|
+
includeResolved?: boolean
|
|
788
|
+
}): Promise<CommentThread[]> => {
|
|
789
|
+
const byThread = new Map<string, Comment[]>()
|
|
790
|
+
for (const c of comments.filter((c) => c.pageId === pageId)) {
|
|
791
|
+
byThread.set(c.threadId, [...(byThread.get(c.threadId) ?? []), c])
|
|
792
|
+
}
|
|
793
|
+
const threads: CommentThread[] = []
|
|
794
|
+
for (const [threadId, list] of byThread) {
|
|
795
|
+
const ordered = [...list].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1))
|
|
796
|
+
// A thread whose root was deleted while replies remain is still somebody's conversation,
|
|
797
|
+
// so the oldest remaining comment leads it — as it does on the server.
|
|
798
|
+
const lead = ordered.find((c) => c.id === threadId) ?? ordered[0]
|
|
799
|
+
if (!lead) continue
|
|
800
|
+
const resolved = Boolean(lead.resolvedAt)
|
|
801
|
+
if (resolved && !includeResolved) continue
|
|
802
|
+
threads.push({
|
|
803
|
+
id: threadId,
|
|
804
|
+
root: structuredClone(lead),
|
|
805
|
+
replies: ordered.filter((c) => c.id !== lead.id).map((c) => structuredClone(c)),
|
|
806
|
+
resolved,
|
|
807
|
+
})
|
|
808
|
+
}
|
|
809
|
+
return threads
|
|
810
|
+
},
|
|
811
|
+
|
|
812
|
+
create: async (input: {
|
|
813
|
+
pageId: string
|
|
814
|
+
body: Record<string, unknown>
|
|
815
|
+
anchor?: CommentAnchor | null
|
|
816
|
+
quotedText?: string
|
|
817
|
+
parentId?: string | null
|
|
818
|
+
}) => {
|
|
819
|
+
const parent = input.parentId ? theComment(input.parentId) : null
|
|
820
|
+
const id = nextId()
|
|
821
|
+
const made: Comment = {
|
|
822
|
+
id,
|
|
823
|
+
workspaceId: '' as Comment['workspaceId'],
|
|
824
|
+
pageId: input.pageId,
|
|
825
|
+
parentId: parent?.id ?? null,
|
|
826
|
+
// A reply belongs to the thread its parent is in, never to a thread of its own.
|
|
827
|
+
threadId: parent?.threadId ?? id,
|
|
828
|
+
authorId: ME as Comment['authorId'],
|
|
829
|
+
body: input.body,
|
|
830
|
+
bodyText: flatten(input.body),
|
|
831
|
+
mentionIds: [],
|
|
832
|
+
anchor: input.anchor ?? null,
|
|
833
|
+
quotedText: input.quotedText ?? '',
|
|
834
|
+
resolvedAt: null,
|
|
835
|
+
resolvedBy: null,
|
|
836
|
+
editedAt: null,
|
|
837
|
+
createdAt: new Date().toISOString(),
|
|
838
|
+
}
|
|
839
|
+
comments.push(made)
|
|
840
|
+
return structuredClone(made)
|
|
841
|
+
},
|
|
842
|
+
|
|
843
|
+
update: async ({ commentId, body }: { commentId: string; body: Record<string, unknown> }) => {
|
|
844
|
+
const found = theComment(commentId)
|
|
845
|
+
found.body = body
|
|
846
|
+
found.bodyText = flatten(body)
|
|
847
|
+
found.editedAt = new Date().toISOString()
|
|
848
|
+
return structuredClone(found)
|
|
849
|
+
},
|
|
850
|
+
|
|
851
|
+
remove: async ({ commentId }: { commentId: string }) => {
|
|
852
|
+
const found = theComment(commentId)
|
|
853
|
+
comments.splice(comments.indexOf(found), 1)
|
|
854
|
+
return { ok: true as const }
|
|
855
|
+
},
|
|
856
|
+
|
|
857
|
+
resolve: async ({ commentId, resolved = true }: { commentId: string; resolved?: boolean }) => {
|
|
858
|
+
const lead = theComment(commentId)
|
|
859
|
+
// Resolving is a property of the conversation, so it is written on the comment that leads
|
|
860
|
+
// it and read from there — never on each reply.
|
|
861
|
+
lead.resolvedAt = resolved ? new Date().toISOString() : null
|
|
862
|
+
lead.resolvedBy = resolved ? (ME as Comment['resolvedBy']) : null
|
|
863
|
+
const rest = comments.filter((c) => c.threadId === lead.threadId && c.id !== lead.id)
|
|
864
|
+
return {
|
|
865
|
+
id: lead.threadId,
|
|
866
|
+
root: structuredClone(lead),
|
|
867
|
+
replies: rest.map((c) => structuredClone(c)),
|
|
868
|
+
resolved,
|
|
869
|
+
}
|
|
870
|
+
},
|
|
871
|
+
},
|
|
872
|
+
|
|
873
|
+
publishing: {
|
|
874
|
+
publish: async ({ pageId, label = null }: { pageId: string; label?: string | null }) => {
|
|
875
|
+
const row = found(pageId)
|
|
876
|
+
if (row.kind !== 'page')
|
|
877
|
+
throw Object.assign(new Error('Only a page has a published version; a live doc is always live'), {
|
|
878
|
+
code: 'BAD_REQUEST',
|
|
879
|
+
})
|
|
880
|
+
const taken = capture(pageId, 'publish', label)
|
|
881
|
+
row.publishedVersionId = taken.id
|
|
882
|
+
row.hasUnpublishedChanges = false
|
|
883
|
+
touch(row)
|
|
884
|
+
return strip(row)
|
|
885
|
+
},
|
|
886
|
+
|
|
887
|
+
revert: async ({ pageId }: { pageId: string }) => {
|
|
888
|
+
const row = found(pageId)
|
|
889
|
+
if (!row.publishedVersionId)
|
|
890
|
+
throw Object.assign(
|
|
891
|
+
new Error('This page has never been published, so there is nothing to go back to'),
|
|
892
|
+
{ code: 'BAD_REQUEST' },
|
|
893
|
+
)
|
|
894
|
+
// The draft being discarded is kept, because discarding it should not be a way to lose an
|
|
895
|
+
// afternoon's writing with no way back.
|
|
896
|
+
capture(pageId, 'auto', null)
|
|
897
|
+
row.hasUnpublishedChanges = false
|
|
898
|
+
touch(row)
|
|
899
|
+
return strip(row)
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
|
|
566
903
|
databases: {
|
|
567
904
|
list: async ({ spaceId }: { spaceId: string }): Promise<DatabaseRef[]> =>
|
|
568
905
|
databases
|