@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.
- package/README.md +93 -55
- package/dist/{server → client}/formula.d.ts +6 -0
- package/dist/client/formula.d.ts.map +1 -0
- package/dist/{server → client}/formula.js +6 -0
- package/dist/client/formula.js.map +1 -0
- package/dist/contract/properties.d.ts +26 -0
- package/dist/contract/properties.d.ts.map +1 -1
- package/dist/contract/properties.js +24 -0
- package/dist/contract/properties.js.map +1 -1
- package/dist/contract/router.d.ts +334 -0
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +43 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/_impl.d.ts +341 -0
- package/dist/server/_impl.d.ts.map +1 -1
- package/dist/server/_impl.js +79 -10
- package/dist/server/_impl.js.map +1 -1
- package/dist/server/services/databases.d.ts +73 -1
- package/dist/server/services/databases.d.ts.map +1 -1
- package/dist/server/services/databases.js +167 -10
- package/dist/server/services/databases.js.map +1 -1
- package/dist/server/services/pages.d.ts.map +1 -1
- package/dist/server/services/pages.js +11 -1
- package/dist/server/services/pages.js.map +1 -1
- package/dist/server/services/query.d.ts.map +1 -1
- package/dist/server/services/query.js +93 -37
- package/dist/server/services/query.js.map +1 -1
- package/migrations/0006_rank_collation.sql +14 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +3 -2
- package/src/client/components/PageTreeRow.svelte +1 -1
- package/src/client/core-api.ts +38 -0
- package/src/client/database/BoardView.svelte +354 -0
- package/src/client/database/CalendarView.svelte +346 -0
- package/src/client/database/DatabaseView.svelte +785 -0
- package/src/client/database/FilterMenu.svelte +281 -0
- package/src/client/database/FilterValue.svelte +176 -0
- package/src/client/database/GalleryView.svelte +177 -0
- package/src/client/database/ListView.svelte +108 -0
- package/src/client/database/OptionChip.svelte +38 -0
- package/src/client/database/PropertyDialog.svelte +460 -0
- package/src/client/database/PropertyMenu.svelte +162 -0
- package/src/client/database/RowPanel.svelte +157 -0
- package/src/client/database/SortMenu.svelte +199 -0
- package/src/client/database/TableView.svelte +388 -0
- package/src/client/database/ViewDialog.svelte +229 -0
- package/src/client/database/cells/Cell.svelte +103 -0
- package/src/client/database/cells/CheckboxCell.svelte +33 -0
- package/src/client/database/cells/ComputedCell.svelte +100 -0
- package/src/client/database/cells/DateCell.svelte +97 -0
- package/src/client/database/cells/LinkCell.svelte +143 -0
- package/src/client/database/cells/NumberCell.svelte +131 -0
- package/src/client/database/cells/PersonCell.svelte +116 -0
- package/src/client/database/cells/RelationCell.svelte +222 -0
- package/src/client/database/cells/SelectCell.svelte +133 -0
- package/src/client/database/cells/TextCell.svelte +85 -0
- package/src/client/database/colours.ts +27 -0
- package/src/client/database/property-types.test.ts +64 -0
- package/src/client/database/property-types.ts +294 -0
- package/src/client/database/view-config.test.ts +148 -0
- package/src/client/database/view-config.ts +102 -0
- package/src/client/formula.test.ts +139 -0
- package/src/client/formula.ts +430 -0
- package/src/client/i18n.ts +1176 -0
- package/src/client/index.ts +31 -0
- package/src/client/mock.ts +511 -1
- package/src/client/pages/PageView.svelte +47 -17
- package/src/client/pages/SpacePage.svelte +8 -2
- package/src/client/query.ts +17 -4
- package/src/contract/properties.ts +28 -0
- package/src/contract/router.ts +46 -0
- package/dist/server/formula.d.ts.map +0 -1
- package/dist/server/formula.js.map +0 -1
package/src/client/index.ts
CHANGED
|
@@ -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
|
/**
|
package/src/client/mock.ts
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
285
|
-
<
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
activeComment =
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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.
|