@kernhq/module-quire 0.8.0 → 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.
Files changed (73) hide show
  1. package/README.md +93 -55
  2. package/dist/{server → client}/formula.d.ts +6 -0
  3. package/dist/client/formula.d.ts.map +1 -0
  4. package/dist/{server → client}/formula.js +6 -0
  5. package/dist/client/formula.js.map +1 -0
  6. package/dist/contract/properties.d.ts +26 -0
  7. package/dist/contract/properties.d.ts.map +1 -1
  8. package/dist/contract/properties.js +24 -0
  9. package/dist/contract/properties.js.map +1 -1
  10. package/dist/contract/router.d.ts +334 -0
  11. package/dist/contract/router.d.ts.map +1 -1
  12. package/dist/contract/router.js +43 -1
  13. package/dist/contract/router.js.map +1 -1
  14. package/dist/server/_impl.d.ts +341 -0
  15. package/dist/server/_impl.d.ts.map +1 -1
  16. package/dist/server/_impl.js +79 -10
  17. package/dist/server/_impl.js.map +1 -1
  18. package/dist/server/services/databases.d.ts +73 -1
  19. package/dist/server/services/databases.d.ts.map +1 -1
  20. package/dist/server/services/databases.js +167 -10
  21. package/dist/server/services/databases.js.map +1 -1
  22. package/dist/server/services/pages.d.ts.map +1 -1
  23. package/dist/server/services/pages.js +11 -1
  24. package/dist/server/services/pages.js.map +1 -1
  25. package/dist/server/services/query.d.ts.map +1 -1
  26. package/dist/server/services/query.js +93 -37
  27. package/dist/server/services/query.js.map +1 -1
  28. package/migrations/0006_rank_collation.sql +14 -0
  29. package/migrations/meta/_journal.json +7 -0
  30. package/package.json +3 -2
  31. package/src/client/components/PageTreeRow.svelte +1 -1
  32. package/src/client/core-api.ts +38 -0
  33. package/src/client/database/BoardView.svelte +354 -0
  34. package/src/client/database/CalendarView.svelte +346 -0
  35. package/src/client/database/DatabaseView.svelte +785 -0
  36. package/src/client/database/FilterMenu.svelte +281 -0
  37. package/src/client/database/FilterValue.svelte +176 -0
  38. package/src/client/database/GalleryView.svelte +177 -0
  39. package/src/client/database/ListView.svelte +108 -0
  40. package/src/client/database/OptionChip.svelte +38 -0
  41. package/src/client/database/PropertyDialog.svelte +460 -0
  42. package/src/client/database/PropertyMenu.svelte +162 -0
  43. package/src/client/database/RowPanel.svelte +157 -0
  44. package/src/client/database/SortMenu.svelte +199 -0
  45. package/src/client/database/TableView.svelte +388 -0
  46. package/src/client/database/ViewDialog.svelte +229 -0
  47. package/src/client/database/cells/Cell.svelte +103 -0
  48. package/src/client/database/cells/CheckboxCell.svelte +33 -0
  49. package/src/client/database/cells/ComputedCell.svelte +100 -0
  50. package/src/client/database/cells/DateCell.svelte +97 -0
  51. package/src/client/database/cells/LinkCell.svelte +143 -0
  52. package/src/client/database/cells/NumberCell.svelte +131 -0
  53. package/src/client/database/cells/PersonCell.svelte +116 -0
  54. package/src/client/database/cells/RelationCell.svelte +222 -0
  55. package/src/client/database/cells/SelectCell.svelte +133 -0
  56. package/src/client/database/cells/TextCell.svelte +85 -0
  57. package/src/client/database/colours.ts +27 -0
  58. package/src/client/database/property-types.test.ts +64 -0
  59. package/src/client/database/property-types.ts +294 -0
  60. package/src/client/database/view-config.test.ts +148 -0
  61. package/src/client/database/view-config.ts +102 -0
  62. package/src/client/formula.test.ts +139 -0
  63. package/src/client/formula.ts +430 -0
  64. package/src/client/i18n.ts +1176 -0
  65. package/src/client/index.ts +31 -0
  66. package/src/client/mock.ts +511 -1
  67. package/src/client/pages/PageView.svelte +47 -17
  68. package/src/client/pages/SpacePage.svelte +8 -2
  69. package/src/client/query.ts +17 -4
  70. package/src/contract/properties.ts +28 -0
  71. package/src/contract/router.ts +46 -0
  72. package/dist/server/formula.d.ts.map +0 -1
  73. package/dist/server/formula.js.map +0 -1
@@ -37,6 +37,37 @@ export {
37
37
  type SpaceVisibility,
38
38
  type VersionKind,
39
39
  } from '../contract/index.js'
40
+ export type { CoreMember, Person } from './core-api.js'
41
+ export { OPTION_COLOURS, toneFor } from './database/colours.js'
42
+ export {
43
+ CREATABLE_TYPES,
44
+ descriptorFor,
45
+ isReadOnly,
46
+ operatorsFor,
47
+ PROPERTY_TYPES,
48
+ type PropertyDescriptor,
49
+ VIEW_KINDS,
50
+ viewIcon,
51
+ } from './database/property-types.js'
52
+ export {
53
+ columnTemplate,
54
+ EMPTY_GROUP,
55
+ groupsOf,
56
+ groupValue,
57
+ type Lane,
58
+ mergeConfig,
59
+ orderedProperties,
60
+ visiblePropertiesOf,
61
+ } from './database/view-config.js'
62
+ export type { Ast, FormulaValue } from './formula.js'
63
+ /**
64
+ * The formula parser, for the property editor.
65
+ *
66
+ * Only the parser: `evaluateFormula` stays a server concern, because a formula is evaluated against
67
+ * a row and the server is the only thing holding one. What a client needs is to tell somebody their
68
+ * expression is wrong while they are typing it, and which columns it reads.
69
+ */
70
+ export { FormulaError, formulaDependencies, parseFormula } from './formula.js'
40
71
  export * from './rank.js'
41
72
 
42
73
  /**
@@ -1,4 +1,18 @@
1
- import type { Page, PageNode, Space } from './index.js'
1
+ import type {
2
+ Database,
3
+ DatabaseRef,
4
+ Row as DatabaseRow,
5
+ Page,
6
+ PageNode,
7
+ Property,
8
+ PropertyConfig,
9
+ PropertyType,
10
+ RowRef,
11
+ Space,
12
+ View,
13
+ ViewConfig,
14
+ ViewKind,
15
+ } from '../contract/index.js'
2
16
 
3
17
  /**
4
18
  * The in-memory quire API.
@@ -18,6 +32,23 @@ const uid = (n: number) => `01920000-0000-7000-8000-0000000${String(n).padStart(
18
32
  interface Row extends Page {
19
33
  /** the mock keeps trashed rows in the same list, as the server does */
20
34
  _order: string
35
+ /** set when this page is a row of a database, exactly as the server's column means it */
36
+ _databaseId?: string
37
+ _props?: Record<string, unknown>
38
+ _computed?: Record<string, unknown>
39
+ }
40
+
41
+ /** The complete `ViewConfig` the server always answers with, so the client can trust its type. */
42
+ const BLANK_VIEW_CONFIG: ViewConfig = {
43
+ filters: [],
44
+ filterMode: 'and',
45
+ sorts: [],
46
+ groupBy: null,
47
+ dateProperty: null,
48
+ visibleProperties: null,
49
+ columnWidths: {},
50
+ cardSize: 'medium',
51
+ coverProperty: null,
21
52
  }
22
53
 
23
54
  export function createMockQuireApi() {
@@ -87,12 +118,125 @@ export function createMockQuireApi() {
87
118
  page(103, uid(1), 'Your first week', 'ba', 102),
88
119
  page(104, uid(1), 'Time off', 'bb', 102),
89
120
  page(105, uid(1), 'Expenses', 'c', null, { kind: 'live' }),
121
+ page(110, uid(1), 'Onboarding tasks', 'd', null, { kind: 'database' }),
90
122
  page(201, uid(2), 'Architecture', 'a'),
91
123
  page(202, uid(2), 'Runbooks', 'b'),
92
124
  page(203, uid(2), 'Deploying', 'ba', 202),
93
125
  page(204, uid(2), 'An old note', 'c', null, { deletedAt: iso(864e5) }),
94
126
  ]
95
127
 
128
+ const DB_ID = uid(120)
129
+ const DB_PAGE = uid(110)
130
+
131
+ /**
132
+ * One seeded database, so `dev:mock` and the end-to-end sweep have a real screen rather than an
133
+ * empty state. Deliberately mixed: one row with every cell empty, so the empty-cell rendering is
134
+ * exercised, and one status option with nothing in it, so an empty board lane is too.
135
+ */
136
+ const prop = (
137
+ n: number,
138
+ key: string,
139
+ name: string,
140
+ type: PropertyType,
141
+ position: string,
142
+ config: PropertyConfig = {},
143
+ ): Property => ({ id: uid(n), databaseId: DB_ID, key, name, type, config, position, hidden: false })
144
+
145
+ const properties: Property[] = [
146
+ prop(121, 'owner', 'Owner', 'person', 'b', { multiple: false }),
147
+ prop(122, 'status', 'Status', 'status', 'c', {
148
+ options: [
149
+ { id: 'todo', label: 'To do', colour: 'slate', group: 'todo' },
150
+ { id: 'doing', label: 'In progress', colour: 'accent', group: 'doing' },
151
+ { id: 'blocked', label: 'Blocked', colour: 'danger', group: 'doing' },
152
+ { id: 'done', label: 'Done', colour: 'success', group: 'done' },
153
+ ],
154
+ }),
155
+ prop(123, 'due', 'Due', 'date', 'd', {}),
156
+ prop(124, 'days', 'Days', 'number', 'e', { precision: 0 }),
157
+ prop(125, 'done', 'Signed off', 'checkbox', 'f', {}),
158
+ prop(126, 'notes', 'Notes', 'text', 'g', {}),
159
+ ]
160
+
161
+ const views: View[] = [
162
+ {
163
+ id: uid(130),
164
+ databaseId: DB_ID,
165
+ name: 'All tasks',
166
+ kind: 'table',
167
+ config: { ...BLANK_VIEW_CONFIG },
168
+ position: 'a',
169
+ isDefault: true,
170
+ },
171
+ {
172
+ id: uid(131),
173
+ databaseId: DB_ID,
174
+ name: 'By status',
175
+ kind: 'board',
176
+ config: { ...BLANK_VIEW_CONFIG, groupBy: 'status' },
177
+ position: 'b',
178
+ isDefault: false,
179
+ },
180
+ {
181
+ id: uid(132),
182
+ databaseId: DB_ID,
183
+ name: 'Schedule',
184
+ kind: 'calendar',
185
+ config: { ...BLANK_VIEW_CONFIG, dateProperty: 'due' },
186
+ position: 'c',
187
+ isDefault: false,
188
+ },
189
+ ]
190
+
191
+ const databases: Database[] = [
192
+ {
193
+ id: DB_ID,
194
+ workspaceId: '' as Database['workspaceId'],
195
+ spaceId: uid(1),
196
+ pageId: DB_PAGE,
197
+ name: 'Onboarding tasks',
198
+ description: '',
199
+ inline: false,
200
+ properties,
201
+ views,
202
+ createdAt: iso(9e7),
203
+ updatedAt: iso(36e5),
204
+ },
205
+ ]
206
+
207
+ const day = (offset: number) => {
208
+ const at = new Date(now + offset * 864e5)
209
+ return new Date(at.getFullYear(), at.getMonth(), at.getDate(), 9).toISOString()
210
+ }
211
+
212
+ const seedRows: [number, string, Record<string, unknown>][] = [
213
+ [140, 'Read the handbook', { status: 'done', due: day(-6), days: 1, done: true, notes: 'Start here' }],
214
+ [141, 'Meet the team', { status: 'done', due: day(-4), days: 1, done: true, notes: '' }],
215
+ [
216
+ 142,
217
+ 'Set up your laptop',
218
+ { status: 'doing', due: day(0), days: 2, done: false, notes: 'IT has the keys' },
219
+ ],
220
+ [143, 'Pick a starter task', { status: 'doing', due: day(1), days: 3, done: false, notes: '' }],
221
+ [144, 'Book a 1:1', { status: 'todo', due: day(3), days: 1, done: false, notes: '' }],
222
+ [
223
+ 145,
224
+ 'Payroll paperwork',
225
+ { status: 'blocked', due: day(2), days: 1, done: false, notes: 'Waiting on HR' },
226
+ ],
227
+ [146, 'Add yourself to the rota', { status: 'todo', due: day(8), days: 1, done: false, notes: '' }],
228
+ // Every cell empty on purpose: this is the row that finds an empty-cell rendering bug.
229
+ [147, 'Anything else?', {}],
230
+ ]
231
+
232
+ for (const [n, title, props] of seedRows) {
233
+ const row = page(n, uid(1), title, `z${n}`, 110)
234
+ row._databaseId = DB_ID
235
+ row._props = props
236
+ row._computed = {}
237
+ pages.push(row)
238
+ }
239
+
96
240
  let seq = 900
97
241
  const nextId = () => uid(++seq)
98
242
  const strip = ({ _order, ...p }: Row): Page => p
@@ -115,6 +259,127 @@ export function createMockQuireApi() {
115
259
  row.updatedAt = new Date().toISOString()
116
260
  }
117
261
 
262
+ const notFound = (what: string) => Object.assign(new Error(`${what} not found`), { code: 'NOT_FOUND' })
263
+
264
+ const theDatabase = (id: string): Database => {
265
+ const db = databases.find((d) => d.id === id)
266
+ if (!db) throw notFound('Database')
267
+ return db
268
+ }
269
+ const theProperty = (id: string): Property => {
270
+ for (const db of databases) {
271
+ const property = db.properties.find((p) => p.id === id)
272
+ if (property) return property
273
+ }
274
+ throw notFound('Property')
275
+ }
276
+ const theView = (id: string): View => {
277
+ for (const db of databases) {
278
+ const view = db.views.find((v) => v.id === id)
279
+ if (view) return view
280
+ }
281
+ throw notFound('View')
282
+ }
283
+
284
+ const toDatabaseRow = (row: Row): DatabaseRow => ({
285
+ id: row.id,
286
+ databaseId: row._databaseId ?? '',
287
+ title: row.title,
288
+ icon: row.icon,
289
+ props: { ...(row._props ?? {}) },
290
+ computed: { ...(row._computed ?? {}) },
291
+ createdBy: row.createdBy,
292
+ updatedBy: row.updatedBy,
293
+ createdAt: row.createdAt,
294
+ updatedAt: row.updatedAt,
295
+ })
296
+
297
+ /**
298
+ * Filtering and sorting in memory.
299
+ *
300
+ * The server does both in SQL, and this is a deliberately smaller reimplementation of the same
301
+ * rules — enough that a filter really does shrink the list and a sort really does reorder it, so
302
+ * the end-to-end tests are testing behaviour rather than a screen that never changes.
303
+ */
304
+ const cellOf = (row: Row, property: Property): unknown =>
305
+ property.type === 'formula' || property.type === 'rollup'
306
+ ? (row._computed ?? {})[property.key]
307
+ : property.type === 'created_time'
308
+ ? row.createdAt
309
+ : property.type === 'edited_time'
310
+ ? row.updatedAt
311
+ : (row._props ?? {})[property.key]
312
+
313
+ const isEmpty = (value: unknown) =>
314
+ value === null || value === undefined || value === '' || (Array.isArray(value) && value.length === 0)
315
+
316
+ function matches(row: Row, operator: string, wanted: unknown, property: Property): boolean {
317
+ const value = cellOf(row, property)
318
+ if (operator === 'is_empty') return isEmpty(value)
319
+ if (operator === 'is_not_empty') return !isEmpty(value)
320
+
321
+ const list = Array.isArray(value) ? value.map(String) : value == null ? [] : [String(value)]
322
+ const asked = Array.isArray(wanted) ? wanted.map(String) : wanted == null ? [] : [String(wanted)]
323
+
324
+ switch (operator) {
325
+ case 'equals':
326
+ return property.type === 'checkbox'
327
+ ? (value === true) === (wanted === true || wanted === 'true')
328
+ : String(value ?? '') === String(wanted ?? '')
329
+ case 'not_equals':
330
+ return String(value ?? '') !== String(wanted ?? '')
331
+ case 'contains':
332
+ return Array.isArray(value)
333
+ ? asked.some((a) => list.includes(a))
334
+ : String(value ?? '')
335
+ .toLowerCase()
336
+ .includes(String(wanted ?? '').toLowerCase())
337
+ case 'not_contains':
338
+ return !(Array.isArray(value)
339
+ ? asked.some((a) => list.includes(a))
340
+ : String(value ?? '')
341
+ .toLowerCase()
342
+ .includes(String(wanted ?? '').toLowerCase()))
343
+ case 'starts_with':
344
+ return String(value ?? '')
345
+ .toLowerCase()
346
+ .startsWith(String(wanted ?? '').toLowerCase())
347
+ case 'ends_with':
348
+ return String(value ?? '')
349
+ .toLowerCase()
350
+ .endsWith(String(wanted ?? '').toLowerCase())
351
+ case 'greater_than':
352
+ return Number(value) > Number(wanted)
353
+ case 'less_than':
354
+ return Number(value) < Number(wanted)
355
+ case 'on_or_after':
356
+ return new Date(String(value)).getTime() >= new Date(String(wanted)).getTime()
357
+ case 'on_or_before':
358
+ return new Date(String(value)).getTime() <= new Date(String(wanted)).getTime()
359
+ case 'is_any_of':
360
+ return asked.some((a) => list.includes(a))
361
+ case 'is_none_of':
362
+ return !asked.some((a) => list.includes(a))
363
+ default:
364
+ return true
365
+ }
366
+ }
367
+
368
+ function compareBy(a: Row, b: Row, property: Property): number {
369
+ const left = cellOf(a, property)
370
+ const right = cellOf(b, property)
371
+ if (isEmpty(left) && isEmpty(right)) return 0
372
+ // Nulls last, whichever way the sort runs — an empty cell is not "the smallest value".
373
+ if (isEmpty(left)) return 1
374
+ if (isEmpty(right)) return -1
375
+ if (property.type === 'number' || property.type === 'formula' || property.type === 'rollup')
376
+ return Number(left) - Number(right)
377
+ if (property.type === 'checkbox') return Number(left === true) - Number(right === true)
378
+ if (property.type === 'date' || property.type === 'created_time' || property.type === 'edited_time')
379
+ return new Date(String(left)).getTime() - new Date(String(right)).getTime()
380
+ return String(left).localeCompare(String(right))
381
+ }
382
+
118
383
  return {
119
384
  spaces: {
120
385
  list: async ({ includeArchived = false }: { includeArchived?: boolean } = {}) =>
@@ -175,6 +440,9 @@ export function createMockQuireApi() {
175
440
  includeArchived?: boolean
176
441
  }): Promise<PageNode[]> => {
177
442
  const rows = pages
443
+ // A row is a page parented to its database's page, and five hundred of them under one
444
+ // node is an unusable sidebar — the server excludes them from the tree for the same reason.
445
+ .filter((p) => !p._databaseId)
178
446
  .filter((p) => p.spaceId === spaceId && !p.deletedAt && (includeArchived || !p.archivedAt))
179
447
  .sort((a, b) => (a._order < b._order ? -1 : a._order > b._order ? 1 : 0))
180
448
  const parents = new Set(rows.map((r) => r.parentId).filter((x): x is string => x !== null))
@@ -283,5 +551,247 @@ export function createMockQuireApi() {
283
551
  return { ok: true as const, count: ids.size }
284
552
  },
285
553
  },
554
+
555
+ databases: {
556
+ list: async ({ spaceId }: { spaceId: string }): Promise<DatabaseRef[]> =>
557
+ databases
558
+ .filter((d) => d.spaceId === spaceId)
559
+ .map((d) => ({ id: d.id, pageId: d.pageId, name: d.name })),
560
+
561
+ get: async ({ databaseId }: { databaseId: string }) => theDatabase(databaseId),
562
+
563
+ forPage: async ({ pageId }: { pageId: string }) => databases.find((d) => d.pageId === pageId) ?? null,
564
+
565
+ create: async (input: { spaceId: string; pageId: string; name?: string; inline?: boolean }) => {
566
+ const db: Database = {
567
+ id: nextId(),
568
+ workspaceId: '' as Database['workspaceId'],
569
+ spaceId: input.spaceId,
570
+ pageId: input.pageId,
571
+ name: input.name ?? '',
572
+ description: '',
573
+ inline: input.inline ?? false,
574
+ properties: [],
575
+ views: [
576
+ {
577
+ id: nextId(),
578
+ databaseId: '',
579
+ name: 'Table',
580
+ kind: 'table',
581
+ config: { ...BLANK_VIEW_CONFIG },
582
+ position: 'a',
583
+ isDefault: true,
584
+ },
585
+ ],
586
+ createdAt: new Date().toISOString(),
587
+ updatedAt: new Date().toISOString(),
588
+ }
589
+ for (const view of db.views) view.databaseId = db.id
590
+ databases.push(db)
591
+ found(input.pageId).kind = 'database'
592
+ return db
593
+ },
594
+
595
+ rows: async (input: {
596
+ databaseId: string
597
+ viewId?: string | null
598
+ limit?: number
599
+ cursor?: string | null
600
+ }) => {
601
+ const db = theDatabase(input.databaseId)
602
+ const view = input.viewId
603
+ ? db.views.find((v) => v.id === input.viewId)
604
+ : (db.views.find((v) => v.isDefault) ?? db.views[0])
605
+ const config = view?.config ?? BLANK_VIEW_CONFIG
606
+
607
+ let selected = pages.filter((r) => r._databaseId === input.databaseId && !r.deletedAt)
608
+ if (config.filters.length > 0)
609
+ selected = selected.filter((row) => {
610
+ const results = config.filters.map((filter) => {
611
+ const property = db.properties.find((p) => p.key === filter.propertyKey)
612
+ if (!property)
613
+ throw Object.assign(new Error(`No such property: ${filter.propertyKey}`), {
614
+ code: 'BAD_REQUEST',
615
+ })
616
+ return matches(row, filter.operator, filter.value, property)
617
+ })
618
+ return config.filterMode === 'or' ? results.some(Boolean) : results.every(Boolean)
619
+ })
620
+
621
+ for (const sort of [...config.sorts].reverse()) {
622
+ const property = db.properties.find((p) => p.key === sort.propertyKey)
623
+ if (!property) continue
624
+ const direction = sort.direction === 'desc' ? -1 : 1
625
+ selected = [...selected].sort((a, b) => direction * compareBy(a, b, property))
626
+ }
627
+
628
+ const limit = input.limit ?? 50
629
+ const from = Number.parseInt(input.cursor ?? '0', 10) || 0
630
+ const window = selected.slice(from, from + limit)
631
+ return {
632
+ items: window.map(toDatabaseRow),
633
+ nextCursor: from + window.length < selected.length ? String(from + window.length) : null,
634
+ }
635
+ },
636
+
637
+ addRow: async (input: { databaseId: string; title?: string; props?: Record<string, unknown> }) => {
638
+ const db = theDatabase(input.databaseId)
639
+ const row = page(++seq, db.spaceId, input.title ?? '', `z${seq}`, null)
640
+ row.id = uid(seq)
641
+ row.parentId = db.pageId
642
+ row._databaseId = db.id
643
+ row._props = { ...(input.props ?? {}) }
644
+ row._computed = {}
645
+ row.createdAt = new Date().toISOString()
646
+ row.updatedAt = row.createdAt
647
+ pages.push(row)
648
+ return toDatabaseRow(row)
649
+ },
650
+
651
+ updateRow: async (input: { rowId: string; title?: string; props?: Record<string, unknown> }) => {
652
+ const row = found(input.rowId)
653
+ if (input.title !== undefined) row.title = input.title
654
+ if (input.props) row._props = { ...(row._props ?? {}), ...input.props }
655
+ touch(row)
656
+ return toDatabaseRow(row)
657
+ },
658
+
659
+ lookup: async (input: {
660
+ databaseId: string
661
+ query?: string
662
+ ids?: string[]
663
+ limit?: number
664
+ }): Promise<RowRef[]> => {
665
+ const term = (input.query ?? '').trim().toLowerCase()
666
+ const ids = input.ids ?? []
667
+ return pages
668
+ .filter((r) => r._databaseId === input.databaseId && !r.deletedAt)
669
+ .filter(
670
+ (r) =>
671
+ ids.includes(r.id) ||
672
+ (term !== '' && r.title.toLowerCase().includes(term)) ||
673
+ (term === '' && ids.length === 0),
674
+ )
675
+ .slice(0, input.limit ?? 25)
676
+ .map((r) => ({ id: r.id, title: r.title, icon: r.icon }))
677
+ },
678
+
679
+ addProperty: async (input: {
680
+ databaseId: string
681
+ name: string
682
+ type: PropertyType
683
+ config?: PropertyConfig
684
+ }) => {
685
+ const db = theDatabase(input.databaseId)
686
+ const taken = new Set(db.properties.map((p) => p.key))
687
+ let key =
688
+ input.name
689
+ .toLowerCase()
690
+ .replace(/[^a-z0-9]+/g, '_')
691
+ .replace(/^_+|_+$/g, '') || 'field'
692
+ let n = 2
693
+ while (taken.has(key)) key = `${key}_${n++}`
694
+ const property: Property = {
695
+ id: nextId(),
696
+ databaseId: db.id,
697
+ key,
698
+ name: input.name,
699
+ type: input.type,
700
+ config: input.config ?? {},
701
+ position: `z${db.properties.length}`,
702
+ hidden: false,
703
+ }
704
+ db.properties.push(property)
705
+ return property
706
+ },
707
+
708
+ updateProperty: async (input: {
709
+ propertyId: string
710
+ name?: string
711
+ type?: PropertyType
712
+ config?: PropertyConfig
713
+ hidden?: boolean
714
+ }) => {
715
+ const property = theProperty(input.propertyId)
716
+ if (input.name !== undefined) property.name = input.name
717
+ if (input.type !== undefined) property.type = input.type
718
+ if (input.config !== undefined) property.config = input.config
719
+ if (input.hidden !== undefined) property.hidden = input.hidden
720
+ return property
721
+ },
722
+
723
+ moveProperty: async (input: { propertyId: string; afterId?: string | null }) => {
724
+ const property = theProperty(input.propertyId)
725
+ const db = theDatabase(property.databaseId)
726
+ const rest = db.properties.filter((p) => p.id !== property.id)
727
+ const at = input.afterId ? rest.findIndex((p) => p.id === input.afterId) : -1
728
+ rest.splice(at + 1, 0, property)
729
+ db.properties.length = 0
730
+ db.properties.push(...rest)
731
+ // The real service mints a fractional index; the mock only has to keep the order it shows.
732
+ db.properties.forEach((p, index) => {
733
+ p.position = String(index).padStart(4, '0')
734
+ })
735
+ return property
736
+ },
737
+
738
+ removeProperty: async (input: { propertyId: string }) => {
739
+ const property = theProperty(input.propertyId)
740
+ const db = theDatabase(property.databaseId)
741
+ db.properties.splice(db.properties.indexOf(property), 1)
742
+ return { ok: true as const }
743
+ },
744
+
745
+ addView: async (input: {
746
+ databaseId: string
747
+ name: string
748
+ kind?: ViewKind
749
+ config?: Partial<ViewConfig>
750
+ }) => {
751
+ const db = theDatabase(input.databaseId)
752
+ const view: View = {
753
+ id: nextId(),
754
+ databaseId: db.id,
755
+ name: input.name,
756
+ kind: input.kind ?? 'table',
757
+ config: { ...BLANK_VIEW_CONFIG, ...(input.config ?? {}) },
758
+ position: `z${db.views.length}`,
759
+ isDefault: db.views.length === 0,
760
+ }
761
+ db.views.push(view)
762
+ return view
763
+ },
764
+
765
+ updateView: async (input: {
766
+ viewId: string
767
+ name?: string
768
+ kind?: ViewKind
769
+ config?: Partial<ViewConfig>
770
+ }) => {
771
+ const view = theView(input.viewId)
772
+ if (input.name !== undefined) view.name = input.name
773
+ if (input.kind !== undefined) view.kind = input.kind
774
+ // Replaced wholesale, exactly as the service does it — the client must send the merged whole.
775
+ if (input.config !== undefined) view.config = { ...BLANK_VIEW_CONFIG, ...input.config }
776
+ return view
777
+ },
778
+
779
+ removeView: async (input: { viewId: string }) => {
780
+ const view = theView(input.viewId)
781
+ const db = theDatabase(view.databaseId)
782
+ if (db.views.length <= 1)
783
+ throw Object.assign(new Error('A database keeps at least one view'), { code: 'BAD_REQUEST' })
784
+ db.views.splice(db.views.indexOf(view), 1)
785
+ if (view.isDefault && db.views[0]) db.views[0].isDefault = true
786
+ return { ok: true as const }
787
+ },
788
+
789
+ setRelation: async (input: { rowId: string; propertyId: string; toPageIds: string[] }) => {
790
+ const property = theProperty(input.propertyId)
791
+ const row = found(input.rowId)
792
+ row._props = { ...(row._props ?? {}), [property.key]: input.toPageIds }
793
+ return { ok: true as const }
794
+ },
795
+ },
286
796
  }
287
797
  }
@@ -18,6 +18,7 @@ import { getQuireApi } from '../api-instance.js'
18
18
  import CommentsPanel from '../components/CommentsPanel.svelte'
19
19
  import PageEditor from '../components/PageEditor.svelte'
20
20
  import VersionHistory from '../components/VersionHistory.svelte'
21
+ import DatabaseView from '../database/DatabaseView.svelte'
21
22
  import { t } from '../i18n.js'
22
23
  import { canQuire } from '../permissions.js'
23
24
  import { quireKeys } from '../query.js'
@@ -29,11 +30,19 @@ import { quireKeys } from '../query.js'
29
30
  * says so rather than drawing an empty box that looks broken — a surface that pretends to be
30
31
  * editable and silently drops what you type is worse than one that admits it is not finished.
31
32
  */
33
+ /**
34
+ * The shell mounts a module route as `<Page {workspaceId} {workspaceSlug} params rest />` — one
35
+ * `params` object, never named props. The named ones are kept because `SpacePage` renders this
36
+ * component directly with a page it already knows.
37
+ */
32
38
  interface Props {
33
- spaceKey: string
34
- pageId: string
39
+ params?: Record<string, string>
40
+ spaceKey?: string
41
+ pageId?: string
35
42
  }
36
- const { spaceKey, pageId }: Props = $props()
43
+ const { params, spaceKey: spaceKeyProp, pageId: pageIdProp }: Props = $props()
44
+ const spaceKey = $derived(spaceKeyProp ?? params?.space ?? '')
45
+ const pageId = $derived(pageIdProp ?? params?.page ?? '')
37
46
 
38
47
  const api = getQuireApi()
39
48
  const client = useQueryClient()
@@ -50,6 +59,8 @@ const query = createQuery(() => ({
50
59
  const doc = $derived(query.data ?? null)
51
60
 
52
61
  const editable = $derived(canQuire('pageEdit'))
62
+ /** A database page draws a view where the prose would be, and a table needs the whole width. */
63
+ const isDatabase = $derived(doc?.kind === 'database')
53
64
  let title = $state('')
54
65
  let dirty = $state(false)
55
66
  let titleEl = $state<HTMLInputElement | null>(null)
@@ -166,7 +177,11 @@ async function trash() {
166
177
  </script>
167
178
 
168
179
  <div class="with-margin" class:open={showComments}>
169
- <Page padding="docs" maxWidth="780px">
180
+ <Page
181
+ padding={isDatabase ? 'none' : 'docs'}
182
+ maxWidth={isDatabase ? undefined : '780px'}
183
+ class={isDatabase ? 'db-page' : undefined}
184
+ >
170
185
  {#if query.isLoading}
171
186
  <Skeleton height="36px" />
172
187
  <div class="gap"></div>
@@ -281,19 +296,23 @@ async function trash() {
281
296
  </div>
282
297
  {/if}
283
298
 
284
- <div class="body">
285
- <PageEditor
286
- {doc}
287
- onpeers={(p) => (peers = p)}
288
- {commentRanges}
289
- {activeComment}
290
- onCommentClick={(id) => (activeComment = id)}
291
- oncomment={(anchor, quotedText) => {
292
- pendingComment = { anchor, quotedText }
293
- activeComment = null
294
- }}
295
- />
296
- </div>
299
+ {#if isDatabase}
300
+ <DatabaseView {workspaceId} {spaceKey} {pageId} spaceId={doc.spaceId} />
301
+ {:else}
302
+ <div class="body">
303
+ <PageEditor
304
+ {doc}
305
+ onpeers={(p) => (peers = p)}
306
+ {commentRanges}
307
+ {activeComment}
308
+ onCommentClick={(id) => (activeComment = id)}
309
+ oncomment={(anchor, quotedText) => {
310
+ pendingComment = { anchor, quotedText }
311
+ activeComment = null
312
+ }}
313
+ />
314
+ </div>
315
+ {/if}
297
316
  {/if}
298
317
  </Page>
299
318
 
@@ -363,6 +382,17 @@ async function trash() {
363
382
  .body {
364
383
  margin-block-start: 22px;
365
384
  }
385
+ /*
386
+ * A database page drops the reading measure and the page padding, because the views set their own
387
+ * (DESIGN.md §2.7: a reading view caps, a working view fills). The title and byline keep the 28px
388
+ * gutter the toolbar under them already uses.
389
+ */
390
+ :global(.db-page) .head {
391
+ padding: 24px 28px 0;
392
+ }
393
+ :global(.db-page) .byline {
394
+ padding-inline: 28px;
395
+ }
366
396
  /*
367
397
  * The margin is a column beside the page rather than an overlay: a comment is about a specific
368
398
  * sentence, and an overlay covers the sentence you are reading it against.