@kernhq/module-quire 0.10.4 → 0.10.6
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 +354 -4
- package/src/client/pages/PageView.svelte +12 -1
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,
|
|
@@ -29,6 +33,21 @@ const iso = (msAgo = 0) => new Date(now - msAgo).toISOString()
|
|
|
29
33
|
|
|
30
34
|
const uid = (n: number) => `01920000-0000-7000-8000-0000000${String(n).padStart(5, '0')}`
|
|
31
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The people the app's own mock signs you in as.
|
|
38
|
+
*
|
|
39
|
+
* Written out rather than derived: this module cannot see the shell's mock, and a comment with
|
|
40
|
+
* nobody's id on it loses the delete control only its author is offered — so the margin would look
|
|
41
|
+
* complete and be missing the one action that belongs to you.
|
|
42
|
+
*
|
|
43
|
+
* Declared here, above every factory that reads them, rather than beside the comment seed that used
|
|
44
|
+
* to own them: the page factory now stamps an author too, and it is called while the module body is
|
|
45
|
+
* still running. A `const` further down the same scope is in its temporal dead zone at that point,
|
|
46
|
+
* so the whole mock throws on import and every Quire screen renders nothing.
|
|
47
|
+
*/
|
|
48
|
+
const ME = '01920000-0000-7000-8000-000000000001'
|
|
49
|
+
const COLLEAGUE = '01920000-0000-7000-8000-000000000002'
|
|
50
|
+
|
|
32
51
|
interface Row extends Page {
|
|
33
52
|
/** the mock keeps trashed rows in the same list, as the server does */
|
|
34
53
|
_order: string
|
|
@@ -102,8 +121,16 @@ export function createMockQuireApi() {
|
|
|
102
121
|
coverUrl: null,
|
|
103
122
|
publishedVersionId: null,
|
|
104
123
|
hasUnpublishedChanges: false,
|
|
105
|
-
|
|
106
|
-
|
|
124
|
+
/*
|
|
125
|
+
* A seeded page has an author, because the byline reads one.
|
|
126
|
+
*
|
|
127
|
+
* These were both null, so `PageView` took its "author unknown" fallback on every page in the
|
|
128
|
+
* demo — the one environment where the byline is ever looked at — and the named path it now
|
|
129
|
+
* has shipped without anything rendering it. `ME` is the demo's signed-in member, so it
|
|
130
|
+
* resolves through `core.workspaces.members.list` like a comment's author does.
|
|
131
|
+
*/
|
|
132
|
+
createdBy: ME as Page['createdBy'],
|
|
133
|
+
updatedBy: ME as Page['updatedBy'],
|
|
107
134
|
createdAt: iso(9e7),
|
|
108
135
|
updatedAt: iso(36e5),
|
|
109
136
|
archivedAt: null,
|
|
@@ -113,8 +140,13 @@ export function createMockQuireApi() {
|
|
|
113
140
|
})
|
|
114
141
|
|
|
115
142
|
const pages: Row[] = [
|
|
116
|
-
page(101, uid(1), 'Welcome', 'a'),
|
|
117
|
-
page
|
|
143
|
+
page(101, uid(1), 'Welcome', 'a', null, { publishedVersionId: uid(152) }),
|
|
144
|
+
// A page with a draft readers cannot see yet, so the banner above the body is reachable. The
|
|
145
|
+
// server only ever sets this on a page that has been published once, and neither does this.
|
|
146
|
+
page(102, uid(1), 'Working here', 'b', null, {
|
|
147
|
+
publishedVersionId: uid(153),
|
|
148
|
+
hasUnpublishedChanges: true,
|
|
149
|
+
}),
|
|
118
150
|
page(103, uid(1), 'Your first week', 'ba', 102),
|
|
119
151
|
page(104, uid(1), 'Time off', 'bb', 102),
|
|
120
152
|
page(105, uid(1), 'Expenses', 'c', null, { kind: 'live' }),
|
|
@@ -237,6 +269,128 @@ export function createMockQuireApi() {
|
|
|
237
269
|
pages.push(row)
|
|
238
270
|
}
|
|
239
271
|
|
|
272
|
+
/**
|
|
273
|
+
* What the pages used to say, and what people have asked about them.
|
|
274
|
+
*
|
|
275
|
+
* Seeded rather than left empty. Version history and the comment margin are two of the three
|
|
276
|
+
* things a page screen is for, and until these existed the demo interface answered the history
|
|
277
|
+
* sheet with "The history could not be loaded" and never drew a margin at all — in exactly the
|
|
278
|
+
* environment used for demos and end-to-end tests. Two pages differ on purpose, so a page with a
|
|
279
|
+
* margin and a page without one are both reachable.
|
|
280
|
+
*/
|
|
281
|
+
const version = (
|
|
282
|
+
n: number,
|
|
283
|
+
pageId: string,
|
|
284
|
+
kind: PageVersion['kind'],
|
|
285
|
+
label: string | null,
|
|
286
|
+
preview: string,
|
|
287
|
+
msAgo: number,
|
|
288
|
+
authorId: string,
|
|
289
|
+
): PageVersion => ({
|
|
290
|
+
id: uid(n),
|
|
291
|
+
workspaceId: '' as PageVersion['workspaceId'],
|
|
292
|
+
pageId,
|
|
293
|
+
kind,
|
|
294
|
+
label,
|
|
295
|
+
preview,
|
|
296
|
+
// The server reports the length of the encoded document; the order of magnitude is all any
|
|
297
|
+
// screen does with it.
|
|
298
|
+
size: preview.length * 4,
|
|
299
|
+
authorId: authorId as PageVersion['authorId'],
|
|
300
|
+
createdAt: iso(msAgo),
|
|
301
|
+
// Which version readers are served is a property of the page, so it is worked out on the way
|
|
302
|
+
// out rather than stored here twice and left to disagree with itself.
|
|
303
|
+
published: false,
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const versions: PageVersion[] = [
|
|
307
|
+
version(150, uid(101), 'publish', 'The first handbook', 'Welcome to Northstar.', 9e7, ME),
|
|
308
|
+
version(
|
|
309
|
+
151,
|
|
310
|
+
uid(101),
|
|
311
|
+
'auto',
|
|
312
|
+
null,
|
|
313
|
+
'Welcome to Northstar. We are a small team and we write things down.',
|
|
314
|
+
108e5,
|
|
315
|
+
COLLEAGUE,
|
|
316
|
+
),
|
|
317
|
+
version(
|
|
318
|
+
152,
|
|
319
|
+
uid(101),
|
|
320
|
+
'publish',
|
|
321
|
+
null,
|
|
322
|
+
'Welcome to Northstar. We are a small team and we write things down, so that nobody has to ask the same question twice.',
|
|
323
|
+
72e5,
|
|
324
|
+
ME,
|
|
325
|
+
),
|
|
326
|
+
version(153, uid(102), 'publish', null, 'How this team works, in one page.', 108e5, ME),
|
|
327
|
+
version(
|
|
328
|
+
154,
|
|
329
|
+
uid(102),
|
|
330
|
+
'auto',
|
|
331
|
+
null,
|
|
332
|
+
'How this team works, in one page. Start with your first week.',
|
|
333
|
+
36e5,
|
|
334
|
+
COLLEAGUE,
|
|
335
|
+
),
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
const richDoc = (text: string): Record<string, unknown> => ({
|
|
339
|
+
type: 'doc',
|
|
340
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
/** The same dumb walk the server does: whatever the editor produced, minus everything but text. */
|
|
344
|
+
const flatten = (body: unknown): string => {
|
|
345
|
+
const out: string[] = []
|
|
346
|
+
const walk = (node: unknown): void => {
|
|
347
|
+
if (!node || typeof node !== 'object') return
|
|
348
|
+
const n = node as { text?: unknown; content?: unknown[] }
|
|
349
|
+
if (typeof n.text === 'string') out.push(n.text)
|
|
350
|
+
if (Array.isArray(n.content)) for (const child of n.content) walk(child)
|
|
351
|
+
}
|
|
352
|
+
walk(body)
|
|
353
|
+
return out.join(' ').replace(/\s+/g, ' ').trim()
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const comment = (
|
|
357
|
+
n: number,
|
|
358
|
+
pageId: string,
|
|
359
|
+
threadId: string,
|
|
360
|
+
parentId: string | null,
|
|
361
|
+
authorId: string,
|
|
362
|
+
text: string,
|
|
363
|
+
msAgo: number,
|
|
364
|
+
): Comment => ({
|
|
365
|
+
id: uid(n),
|
|
366
|
+
workspaceId: '' as Comment['workspaceId'],
|
|
367
|
+
pageId,
|
|
368
|
+
parentId,
|
|
369
|
+
threadId,
|
|
370
|
+
authorId: authorId as Comment['authorId'],
|
|
371
|
+
body: richDoc(text),
|
|
372
|
+
bodyText: text,
|
|
373
|
+
mentionIds: [],
|
|
374
|
+
/*
|
|
375
|
+
* No anchor, and none of these quote anything.
|
|
376
|
+
*
|
|
377
|
+
* An anchor is a pair of Yjs relative positions into a document that only exists behind the
|
|
378
|
+
* collab service, and there is no collab service here — a made-up one would point at nothing
|
|
379
|
+
* and the editor would draw a highlight over the wrong words, which is worse than no highlight.
|
|
380
|
+
*/
|
|
381
|
+
anchor: null,
|
|
382
|
+
quotedText: '',
|
|
383
|
+
resolvedAt: null,
|
|
384
|
+
resolvedBy: null,
|
|
385
|
+
editedAt: null,
|
|
386
|
+
createdAt: iso(msAgo),
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
const comments: Comment[] = [
|
|
390
|
+
comment(160, uid(101), uid(160), null, COLLEAGUE, 'Should this mention the on-call rota?', 72e5),
|
|
391
|
+
comment(161, uid(101), uid(160), uid(160), ME, 'Good point — I will link to it from here.', 36e5),
|
|
392
|
+
]
|
|
393
|
+
|
|
240
394
|
let seq = 900
|
|
241
395
|
const nextId = () => uid(++seq)
|
|
242
396
|
const strip = ({ _order, ...p }: Row): Page => p
|
|
@@ -261,6 +415,43 @@ export function createMockQuireApi() {
|
|
|
261
415
|
|
|
262
416
|
const notFound = (what: string) => Object.assign(new Error(`${what} not found`), { code: 'NOT_FOUND' })
|
|
263
417
|
|
|
418
|
+
const theVersion = (id: string): PageVersion => {
|
|
419
|
+
const found = versions.find((v) => v.id === id)
|
|
420
|
+
if (!found) throw notFound('Version')
|
|
421
|
+
return found
|
|
422
|
+
}
|
|
423
|
+
const theComment = (id: string): Comment => {
|
|
424
|
+
const found = comments.find((c) => c.id === id)
|
|
425
|
+
if (!found) throw notFound('Comment')
|
|
426
|
+
return found
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Which version a reader is served, which the list and the sheet both have to agree about. */
|
|
430
|
+
const publishedOn = (pageId: string) => pages.find((p) => p.id === pageId)?.publishedVersionId ?? null
|
|
431
|
+
const asVersion = (v: PageVersion): PageVersion => ({ ...v, published: v.id === publishedOn(v.pageId) })
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Write down what the page says now, exactly where the server takes a version.
|
|
435
|
+
*
|
|
436
|
+
* There is no document behind this, so the newest version's prose stands in for the live one —
|
|
437
|
+
* enough that restoring writes a new row saying what it restored, which is the behaviour the
|
|
438
|
+
* history sheet is judged on.
|
|
439
|
+
*/
|
|
440
|
+
const capture = (pageId: string, kind: PageVersion['kind'], label: string | null, preview?: string) => {
|
|
441
|
+
const latest = versions.filter((v) => v.pageId === pageId).at(-1)
|
|
442
|
+
const taken = version(
|
|
443
|
+
++seq,
|
|
444
|
+
pageId,
|
|
445
|
+
kind,
|
|
446
|
+
label,
|
|
447
|
+
preview ?? latest?.preview ?? found(pageId).title,
|
|
448
|
+
0,
|
|
449
|
+
ME,
|
|
450
|
+
)
|
|
451
|
+
versions.push(taken)
|
|
452
|
+
return taken
|
|
453
|
+
}
|
|
454
|
+
|
|
264
455
|
const theDatabase = (id: string): Database => {
|
|
265
456
|
const db = databases.find((d) => d.id === id)
|
|
266
457
|
if (!db) throw notFound('Database')
|
|
@@ -563,6 +754,165 @@ export function createMockQuireApi() {
|
|
|
563
754
|
},
|
|
564
755
|
},
|
|
565
756
|
|
|
757
|
+
versions: {
|
|
758
|
+
list: async ({ pageId, limit = 50 }: { pageId: string; limit?: number }) => ({
|
|
759
|
+
// Newest first, and the ids sort because they are minted in order — the same thing the
|
|
760
|
+
// server gets from ordering on a uuidv7.
|
|
761
|
+
items: versions
|
|
762
|
+
.filter((v) => v.pageId === pageId)
|
|
763
|
+
.sort((a, b) => (a.id < b.id ? 1 : -1))
|
|
764
|
+
.slice(0, limit)
|
|
765
|
+
.map(asVersion),
|
|
766
|
+
nextCursor: null,
|
|
767
|
+
}),
|
|
768
|
+
|
|
769
|
+
get: async ({ versionId }: { versionId: string }) => {
|
|
770
|
+
const found = theVersion(versionId)
|
|
771
|
+
return {
|
|
772
|
+
...asVersion(found),
|
|
773
|
+
text: found.preview,
|
|
774
|
+
// The server renders the stored document; the escaping is the part worth keeping, since
|
|
775
|
+
// a screen hands this straight to a renderer.
|
|
776
|
+
html: `<p>${found.preview.replace(/&/g, '&').replace(/</g, '<')}</p>`,
|
|
777
|
+
}
|
|
778
|
+
},
|
|
779
|
+
|
|
780
|
+
create: async ({ pageId, label = null }: { pageId: string; label?: string | null }) =>
|
|
781
|
+
asVersion(capture(pageId, 'auto', label)),
|
|
782
|
+
|
|
783
|
+
restore: async ({ versionId }: { versionId: string }) => {
|
|
784
|
+
const wanted = theVersion(versionId)
|
|
785
|
+
// The state about to be replaced is captured first, so restoring is itself undoable — the
|
|
786
|
+
// reason the sheet offers it without a confirmation.
|
|
787
|
+
capture(wanted.pageId, 'auto', null)
|
|
788
|
+
const restored = capture(wanted.pageId, 'restore', wanted.label, wanted.preview)
|
|
789
|
+
touch(found(wanted.pageId))
|
|
790
|
+
return asVersion(restored)
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
|
|
794
|
+
comments: {
|
|
795
|
+
list: async ({
|
|
796
|
+
pageId,
|
|
797
|
+
includeResolved = false,
|
|
798
|
+
}: {
|
|
799
|
+
pageId: string
|
|
800
|
+
includeResolved?: boolean
|
|
801
|
+
}): Promise<CommentThread[]> => {
|
|
802
|
+
const byThread = new Map<string, Comment[]>()
|
|
803
|
+
for (const c of comments.filter((c) => c.pageId === pageId)) {
|
|
804
|
+
byThread.set(c.threadId, [...(byThread.get(c.threadId) ?? []), c])
|
|
805
|
+
}
|
|
806
|
+
const threads: CommentThread[] = []
|
|
807
|
+
for (const [threadId, list] of byThread) {
|
|
808
|
+
const ordered = [...list].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1))
|
|
809
|
+
// A thread whose root was deleted while replies remain is still somebody's conversation,
|
|
810
|
+
// so the oldest remaining comment leads it — as it does on the server.
|
|
811
|
+
const lead = ordered.find((c) => c.id === threadId) ?? ordered[0]
|
|
812
|
+
if (!lead) continue
|
|
813
|
+
const resolved = Boolean(lead.resolvedAt)
|
|
814
|
+
if (resolved && !includeResolved) continue
|
|
815
|
+
threads.push({
|
|
816
|
+
id: threadId,
|
|
817
|
+
root: structuredClone(lead),
|
|
818
|
+
replies: ordered.filter((c) => c.id !== lead.id).map((c) => structuredClone(c)),
|
|
819
|
+
resolved,
|
|
820
|
+
})
|
|
821
|
+
}
|
|
822
|
+
return threads
|
|
823
|
+
},
|
|
824
|
+
|
|
825
|
+
create: async (input: {
|
|
826
|
+
pageId: string
|
|
827
|
+
body: Record<string, unknown>
|
|
828
|
+
anchor?: CommentAnchor | null
|
|
829
|
+
quotedText?: string
|
|
830
|
+
parentId?: string | null
|
|
831
|
+
}) => {
|
|
832
|
+
const parent = input.parentId ? theComment(input.parentId) : null
|
|
833
|
+
const id = nextId()
|
|
834
|
+
const made: Comment = {
|
|
835
|
+
id,
|
|
836
|
+
workspaceId: '' as Comment['workspaceId'],
|
|
837
|
+
pageId: input.pageId,
|
|
838
|
+
parentId: parent?.id ?? null,
|
|
839
|
+
// A reply belongs to the thread its parent is in, never to a thread of its own.
|
|
840
|
+
threadId: parent?.threadId ?? id,
|
|
841
|
+
authorId: ME as Comment['authorId'],
|
|
842
|
+
body: input.body,
|
|
843
|
+
bodyText: flatten(input.body),
|
|
844
|
+
mentionIds: [],
|
|
845
|
+
anchor: input.anchor ?? null,
|
|
846
|
+
quotedText: input.quotedText ?? '',
|
|
847
|
+
resolvedAt: null,
|
|
848
|
+
resolvedBy: null,
|
|
849
|
+
editedAt: null,
|
|
850
|
+
createdAt: new Date().toISOString(),
|
|
851
|
+
}
|
|
852
|
+
comments.push(made)
|
|
853
|
+
return structuredClone(made)
|
|
854
|
+
},
|
|
855
|
+
|
|
856
|
+
update: async ({ commentId, body }: { commentId: string; body: Record<string, unknown> }) => {
|
|
857
|
+
const found = theComment(commentId)
|
|
858
|
+
found.body = body
|
|
859
|
+
found.bodyText = flatten(body)
|
|
860
|
+
found.editedAt = new Date().toISOString()
|
|
861
|
+
return structuredClone(found)
|
|
862
|
+
},
|
|
863
|
+
|
|
864
|
+
remove: async ({ commentId }: { commentId: string }) => {
|
|
865
|
+
const found = theComment(commentId)
|
|
866
|
+
comments.splice(comments.indexOf(found), 1)
|
|
867
|
+
return { ok: true as const }
|
|
868
|
+
},
|
|
869
|
+
|
|
870
|
+
resolve: async ({ commentId, resolved = true }: { commentId: string; resolved?: boolean }) => {
|
|
871
|
+
const lead = theComment(commentId)
|
|
872
|
+
// Resolving is a property of the conversation, so it is written on the comment that leads
|
|
873
|
+
// it and read from there — never on each reply.
|
|
874
|
+
lead.resolvedAt = resolved ? new Date().toISOString() : null
|
|
875
|
+
lead.resolvedBy = resolved ? (ME as Comment['resolvedBy']) : null
|
|
876
|
+
const rest = comments.filter((c) => c.threadId === lead.threadId && c.id !== lead.id)
|
|
877
|
+
return {
|
|
878
|
+
id: lead.threadId,
|
|
879
|
+
root: structuredClone(lead),
|
|
880
|
+
replies: rest.map((c) => structuredClone(c)),
|
|
881
|
+
resolved,
|
|
882
|
+
}
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
|
|
886
|
+
publishing: {
|
|
887
|
+
publish: async ({ pageId, label = null }: { pageId: string; label?: string | null }) => {
|
|
888
|
+
const row = found(pageId)
|
|
889
|
+
if (row.kind !== 'page')
|
|
890
|
+
throw Object.assign(new Error('Only a page has a published version; a live doc is always live'), {
|
|
891
|
+
code: 'BAD_REQUEST',
|
|
892
|
+
})
|
|
893
|
+
const taken = capture(pageId, 'publish', label)
|
|
894
|
+
row.publishedVersionId = taken.id
|
|
895
|
+
row.hasUnpublishedChanges = false
|
|
896
|
+
touch(row)
|
|
897
|
+
return strip(row)
|
|
898
|
+
},
|
|
899
|
+
|
|
900
|
+
revert: async ({ pageId }: { pageId: string }) => {
|
|
901
|
+
const row = found(pageId)
|
|
902
|
+
if (!row.publishedVersionId)
|
|
903
|
+
throw Object.assign(
|
|
904
|
+
new Error('This page has never been published, so there is nothing to go back to'),
|
|
905
|
+
{ code: 'BAD_REQUEST' },
|
|
906
|
+
)
|
|
907
|
+
// The draft being discarded is kept, because discarding it should not be a way to lose an
|
|
908
|
+
// afternoon's writing with no way back.
|
|
909
|
+
capture(pageId, 'auto', null)
|
|
910
|
+
row.hasUnpublishedChanges = false
|
|
911
|
+
touch(row)
|
|
912
|
+
return strip(row)
|
|
913
|
+
},
|
|
914
|
+
},
|
|
915
|
+
|
|
566
916
|
databases: {
|
|
567
917
|
list: async ({ spaceId }: { spaceId: string }): Promise<DatabaseRef[]> =>
|
|
568
918
|
databases
|
|
@@ -301,7 +301,18 @@ async function trash() {
|
|
|
301
301
|
</div>
|
|
302
302
|
|
|
303
303
|
<div class="byline">
|
|
304
|
-
|
|
304
|
+
<!--
|
|
305
|
+
No avatar for an author nobody can name.
|
|
306
|
+
|
|
307
|
+
`Avatar` with no `name` draws a "?" disc whose `title` is empty, so an unresolved author put
|
|
308
|
+
a meaningless glyph at the head of the one line whose job is to say who touched the page —
|
|
309
|
+
and a screen reader read it out as "question mark". `updatedBy` is null for every page the
|
|
310
|
+
demo seeds and for any row written before the column existed, so this is the common path,
|
|
311
|
+
not the edge. The sentence beside it already falls back to wording that claims no author.
|
|
312
|
+
-->
|
|
313
|
+
{#if editor}
|
|
314
|
+
<Avatar id={doc.updatedBy} name={editor.name} src={editor.avatarUrl ?? undefined} size={24} />
|
|
315
|
+
{/if}
|
|
305
316
|
<span>
|
|
306
317
|
{editor
|
|
307
318
|
? t('edited_ago_by', { when: relativeTime(doc.updatedAt), who: editor.name })
|