@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
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import type { FilterOperator, PropertyType, ViewKind } from '../../contract/index.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What every property type is, in one table.
|
|
5
|
+
*
|
|
6
|
+
* The interface asks the same four questions of a column over and over — which icon, which editor,
|
|
7
|
+
* may it be written, which operators may a filter offer — and answering each of them with its own
|
|
8
|
+
* `switch` is how a new property type ends up drawing nothing in one place and everything in
|
|
9
|
+
* another. One record, exhaustively typed: adding a type to the contract stops compiling here until
|
|
10
|
+
* it is described, which is the point.
|
|
11
|
+
*
|
|
12
|
+
* Icons are chosen from `@kernhq/ui`'s registry as it actually stands. An unregistered name renders
|
|
13
|
+
* a blank square and throws nothing, and there is no `table`, `sigma` or `arrow-down` in it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The family of editor a cell uses. `unsupported` renders read-only and says why. */
|
|
17
|
+
export type CellEditor =
|
|
18
|
+
| 'text'
|
|
19
|
+
| 'number'
|
|
20
|
+
| 'select'
|
|
21
|
+
| 'date'
|
|
22
|
+
| 'person'
|
|
23
|
+
| 'checkbox'
|
|
24
|
+
| 'link'
|
|
25
|
+
| 'relation'
|
|
26
|
+
| 'computed'
|
|
27
|
+
| 'unsupported'
|
|
28
|
+
|
|
29
|
+
export interface PropertyDescriptor {
|
|
30
|
+
icon: string
|
|
31
|
+
editor: CellEditor
|
|
32
|
+
/** written by the server, never by a person — a formula, a rollup, or an audit stamp */
|
|
33
|
+
readOnly: boolean
|
|
34
|
+
operators: FilterOperator[]
|
|
35
|
+
/** may a board be grouped by it */
|
|
36
|
+
canGroup: boolean
|
|
37
|
+
/** may a calendar be plotted on it */
|
|
38
|
+
canDate: boolean
|
|
39
|
+
/** may somebody choose it when adding a column */
|
|
40
|
+
creatable: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TEXTUAL: FilterOperator[] = [
|
|
44
|
+
'equals',
|
|
45
|
+
'not_equals',
|
|
46
|
+
'contains',
|
|
47
|
+
'not_contains',
|
|
48
|
+
'starts_with',
|
|
49
|
+
'ends_with',
|
|
50
|
+
'is_empty',
|
|
51
|
+
'is_not_empty',
|
|
52
|
+
]
|
|
53
|
+
const NUMERIC: FilterOperator[] = [
|
|
54
|
+
'equals',
|
|
55
|
+
'not_equals',
|
|
56
|
+
'greater_than',
|
|
57
|
+
'less_than',
|
|
58
|
+
'is_empty',
|
|
59
|
+
'is_not_empty',
|
|
60
|
+
]
|
|
61
|
+
const DATED: FilterOperator[] = [
|
|
62
|
+
'equals',
|
|
63
|
+
'on_or_before',
|
|
64
|
+
'on_or_after',
|
|
65
|
+
'greater_than',
|
|
66
|
+
'less_than',
|
|
67
|
+
'is_empty',
|
|
68
|
+
'is_not_empty',
|
|
69
|
+
]
|
|
70
|
+
const CHOSEN: FilterOperator[] = [
|
|
71
|
+
'equals',
|
|
72
|
+
'not_equals',
|
|
73
|
+
'is_any_of',
|
|
74
|
+
'is_none_of',
|
|
75
|
+
'is_empty',
|
|
76
|
+
'is_not_empty',
|
|
77
|
+
]
|
|
78
|
+
const MANY: FilterOperator[] = [
|
|
79
|
+
'contains',
|
|
80
|
+
'not_contains',
|
|
81
|
+
'is_any_of',
|
|
82
|
+
'is_none_of',
|
|
83
|
+
'is_empty',
|
|
84
|
+
'is_not_empty',
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
export const PROPERTY_TYPES: Record<PropertyType, PropertyDescriptor> = {
|
|
88
|
+
text: {
|
|
89
|
+
icon: 'file-text',
|
|
90
|
+
editor: 'text',
|
|
91
|
+
readOnly: false,
|
|
92
|
+
operators: TEXTUAL,
|
|
93
|
+
canGroup: false,
|
|
94
|
+
canDate: false,
|
|
95
|
+
creatable: true,
|
|
96
|
+
},
|
|
97
|
+
number: {
|
|
98
|
+
icon: 'hash',
|
|
99
|
+
editor: 'number',
|
|
100
|
+
readOnly: false,
|
|
101
|
+
operators: NUMERIC,
|
|
102
|
+
canGroup: false,
|
|
103
|
+
canDate: false,
|
|
104
|
+
creatable: true,
|
|
105
|
+
},
|
|
106
|
+
select: {
|
|
107
|
+
icon: 'chevron-down',
|
|
108
|
+
editor: 'select',
|
|
109
|
+
readOnly: false,
|
|
110
|
+
operators: CHOSEN,
|
|
111
|
+
canGroup: true,
|
|
112
|
+
canDate: false,
|
|
113
|
+
creatable: true,
|
|
114
|
+
},
|
|
115
|
+
multi_select: {
|
|
116
|
+
icon: 'tag',
|
|
117
|
+
editor: 'select',
|
|
118
|
+
readOnly: false,
|
|
119
|
+
operators: MANY,
|
|
120
|
+
canGroup: false,
|
|
121
|
+
canDate: false,
|
|
122
|
+
creatable: true,
|
|
123
|
+
},
|
|
124
|
+
status: {
|
|
125
|
+
icon: 'circle-check',
|
|
126
|
+
editor: 'select',
|
|
127
|
+
readOnly: false,
|
|
128
|
+
operators: CHOSEN,
|
|
129
|
+
canGroup: true,
|
|
130
|
+
canDate: false,
|
|
131
|
+
creatable: true,
|
|
132
|
+
},
|
|
133
|
+
date: {
|
|
134
|
+
icon: 'calendar',
|
|
135
|
+
editor: 'date',
|
|
136
|
+
readOnly: false,
|
|
137
|
+
operators: DATED,
|
|
138
|
+
canGroup: false,
|
|
139
|
+
canDate: true,
|
|
140
|
+
creatable: true,
|
|
141
|
+
},
|
|
142
|
+
person: {
|
|
143
|
+
icon: 'user',
|
|
144
|
+
editor: 'person',
|
|
145
|
+
readOnly: false,
|
|
146
|
+
operators: MANY,
|
|
147
|
+
canGroup: false,
|
|
148
|
+
canDate: false,
|
|
149
|
+
creatable: true,
|
|
150
|
+
},
|
|
151
|
+
/**
|
|
152
|
+
* Quire has no file handling at all — no upload path, no storage ticket — so a `files` column is
|
|
153
|
+
* drawn read-only and is not offered when adding one. A picker that produces a column nobody can
|
|
154
|
+
* fill is worse than an absent type.
|
|
155
|
+
*/
|
|
156
|
+
files: {
|
|
157
|
+
icon: 'paperclip',
|
|
158
|
+
editor: 'unsupported',
|
|
159
|
+
readOnly: true,
|
|
160
|
+
operators: ['is_empty', 'is_not_empty'],
|
|
161
|
+
canGroup: false,
|
|
162
|
+
canDate: false,
|
|
163
|
+
creatable: false,
|
|
164
|
+
},
|
|
165
|
+
checkbox: {
|
|
166
|
+
icon: 'square-check-big',
|
|
167
|
+
editor: 'checkbox',
|
|
168
|
+
readOnly: false,
|
|
169
|
+
operators: ['equals', 'is_empty', 'is_not_empty'],
|
|
170
|
+
canGroup: true,
|
|
171
|
+
canDate: false,
|
|
172
|
+
creatable: true,
|
|
173
|
+
},
|
|
174
|
+
url: {
|
|
175
|
+
icon: 'link',
|
|
176
|
+
editor: 'link',
|
|
177
|
+
readOnly: false,
|
|
178
|
+
operators: TEXTUAL,
|
|
179
|
+
canGroup: false,
|
|
180
|
+
canDate: false,
|
|
181
|
+
creatable: true,
|
|
182
|
+
},
|
|
183
|
+
email: {
|
|
184
|
+
icon: 'at-sign',
|
|
185
|
+
editor: 'link',
|
|
186
|
+
readOnly: false,
|
|
187
|
+
operators: TEXTUAL,
|
|
188
|
+
canGroup: false,
|
|
189
|
+
canDate: false,
|
|
190
|
+
creatable: true,
|
|
191
|
+
},
|
|
192
|
+
phone: {
|
|
193
|
+
icon: 'smartphone',
|
|
194
|
+
editor: 'link',
|
|
195
|
+
readOnly: false,
|
|
196
|
+
operators: TEXTUAL,
|
|
197
|
+
canGroup: false,
|
|
198
|
+
canDate: false,
|
|
199
|
+
creatable: true,
|
|
200
|
+
},
|
|
201
|
+
relation: {
|
|
202
|
+
icon: 'git-branch',
|
|
203
|
+
editor: 'relation',
|
|
204
|
+
readOnly: false,
|
|
205
|
+
operators: MANY,
|
|
206
|
+
canGroup: false,
|
|
207
|
+
canDate: false,
|
|
208
|
+
creatable: true,
|
|
209
|
+
},
|
|
210
|
+
rollup: {
|
|
211
|
+
icon: 'chart-column',
|
|
212
|
+
editor: 'computed',
|
|
213
|
+
readOnly: true,
|
|
214
|
+
operators: NUMERIC,
|
|
215
|
+
canGroup: false,
|
|
216
|
+
canDate: false,
|
|
217
|
+
creatable: true,
|
|
218
|
+
},
|
|
219
|
+
formula: {
|
|
220
|
+
icon: 'code',
|
|
221
|
+
editor: 'computed',
|
|
222
|
+
readOnly: true,
|
|
223
|
+
operators: NUMERIC,
|
|
224
|
+
canGroup: false,
|
|
225
|
+
canDate: false,
|
|
226
|
+
creatable: true,
|
|
227
|
+
},
|
|
228
|
+
created_time: {
|
|
229
|
+
icon: 'clock',
|
|
230
|
+
editor: 'computed',
|
|
231
|
+
readOnly: true,
|
|
232
|
+
operators: DATED,
|
|
233
|
+
canGroup: false,
|
|
234
|
+
canDate: true,
|
|
235
|
+
creatable: true,
|
|
236
|
+
},
|
|
237
|
+
created_by: {
|
|
238
|
+
icon: 'circle-user',
|
|
239
|
+
editor: 'computed',
|
|
240
|
+
readOnly: true,
|
|
241
|
+
operators: CHOSEN,
|
|
242
|
+
canGroup: false,
|
|
243
|
+
canDate: false,
|
|
244
|
+
creatable: true,
|
|
245
|
+
},
|
|
246
|
+
edited_time: {
|
|
247
|
+
icon: 'clock',
|
|
248
|
+
editor: 'computed',
|
|
249
|
+
readOnly: true,
|
|
250
|
+
operators: DATED,
|
|
251
|
+
canGroup: false,
|
|
252
|
+
canDate: true,
|
|
253
|
+
creatable: true,
|
|
254
|
+
},
|
|
255
|
+
edited_by: {
|
|
256
|
+
icon: 'circle-user',
|
|
257
|
+
editor: 'computed',
|
|
258
|
+
readOnly: true,
|
|
259
|
+
operators: CHOSEN,
|
|
260
|
+
canGroup: false,
|
|
261
|
+
canDate: false,
|
|
262
|
+
creatable: true,
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const descriptorFor = (type: PropertyType): PropertyDescriptor => PROPERTY_TYPES[type]
|
|
267
|
+
export const operatorsFor = (type: PropertyType): FilterOperator[] => PROPERTY_TYPES[type].operators
|
|
268
|
+
export const isReadOnly = (type: PropertyType): boolean => PROPERTY_TYPES[type].readOnly
|
|
269
|
+
|
|
270
|
+
/** The types somebody may choose when adding or retyping a column, in the order they are offered. */
|
|
271
|
+
export const CREATABLE_TYPES: PropertyType[] = (Object.keys(PROPERTY_TYPES) as PropertyType[]).filter(
|
|
272
|
+
(type) => PROPERTY_TYPES[type].creatable,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
/** Operators that take no value at all — the value editor is hidden rather than disabled. */
|
|
276
|
+
export const VALUELESS_OPERATORS: FilterOperator[] = ['is_empty', 'is_not_empty']
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Timeline is declared by `ViewKind` and is not built.
|
|
280
|
+
*
|
|
281
|
+
* It needs a start and an end date per row, a scale and a horizontal virtualiser, and none of that
|
|
282
|
+
* exists — so it is left out of the kinds the interface offers rather than shipped as a tab that
|
|
283
|
+
* renders nothing. A view already saved as `timeline` falls back to the table.
|
|
284
|
+
*/
|
|
285
|
+
export const VIEW_KINDS: { kind: ViewKind; icon: string }[] = [
|
|
286
|
+
{ kind: 'table', icon: 'columns-3' },
|
|
287
|
+
{ kind: 'board', icon: 'kanban' },
|
|
288
|
+
{ kind: 'gallery', icon: 'layout-grid' },
|
|
289
|
+
{ kind: 'list', icon: 'list' },
|
|
290
|
+
{ kind: 'calendar', icon: 'calendar-days' },
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
export const viewIcon = (kind: ViewKind): string =>
|
|
294
|
+
VIEW_KINDS.find((v) => v.kind === kind)?.icon ?? 'columns-3'
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `updateView` replaces `config` wholesale, so every one of these assertions is about the same
|
|
3
|
+
* failure: a write that carried a fragment and silently deleted the rest of somebody's view.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import type { Database, Filter, Property, Row, Sort, ViewConfig } from '../../contract/index.js'
|
|
7
|
+
import {
|
|
8
|
+
columnTemplate,
|
|
9
|
+
EMPTY_GROUP,
|
|
10
|
+
groupsOf,
|
|
11
|
+
groupValue,
|
|
12
|
+
mergeConfig,
|
|
13
|
+
visiblePropertiesOf,
|
|
14
|
+
} from './view-config.js'
|
|
15
|
+
|
|
16
|
+
const config = (over: Partial<ViewConfig> = {}): ViewConfig => ({
|
|
17
|
+
filters: [],
|
|
18
|
+
filterMode: 'and',
|
|
19
|
+
sorts: [],
|
|
20
|
+
groupBy: null,
|
|
21
|
+
dateProperty: null,
|
|
22
|
+
visibleProperties: null,
|
|
23
|
+
columnWidths: {},
|
|
24
|
+
cardSize: 'medium',
|
|
25
|
+
coverProperty: null,
|
|
26
|
+
...over,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const property = (key: string, over: Partial<Property> = {}): Property =>
|
|
30
|
+
({
|
|
31
|
+
id: `p-${key}`,
|
|
32
|
+
databaseId: 'db',
|
|
33
|
+
key,
|
|
34
|
+
name: key,
|
|
35
|
+
type: 'text',
|
|
36
|
+
config: {},
|
|
37
|
+
position: key,
|
|
38
|
+
hidden: false,
|
|
39
|
+
...over,
|
|
40
|
+
}) as Property
|
|
41
|
+
|
|
42
|
+
const row = (id: string, props: Record<string, unknown>): Row =>
|
|
43
|
+
({
|
|
44
|
+
id,
|
|
45
|
+
databaseId: 'db',
|
|
46
|
+
title: id,
|
|
47
|
+
icon: null,
|
|
48
|
+
props,
|
|
49
|
+
computed: {},
|
|
50
|
+
createdBy: null,
|
|
51
|
+
updatedBy: null,
|
|
52
|
+
createdAt: '',
|
|
53
|
+
updatedAt: '',
|
|
54
|
+
}) as Row
|
|
55
|
+
|
|
56
|
+
describe('mergeConfig', () => {
|
|
57
|
+
it('keeps everything the patch does not mention', () => {
|
|
58
|
+
const filter: Filter = { propertyKey: 'status', operator: 'equals', value: 'done' }
|
|
59
|
+
const merged = mergeConfig(config({ filters: [filter], groupBy: 'status', cardSize: 'large' }), {
|
|
60
|
+
sorts: [],
|
|
61
|
+
})
|
|
62
|
+
expect(merged.filters, 'a partial write deleted the filters — the whole point of this helper').toEqual([
|
|
63
|
+
filter,
|
|
64
|
+
])
|
|
65
|
+
expect(merged.groupBy).toBe('status')
|
|
66
|
+
expect(merged.cardSize).toBe('large')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('lets a patch clear a nullable field rather than treating null as absent', () => {
|
|
70
|
+
const merged = mergeConfig(config({ groupBy: 'status', coverProperty: 'cover' }), { groupBy: null })
|
|
71
|
+
expect(merged.groupBy).toBeNull()
|
|
72
|
+
expect(merged.coverProperty, 'only groupBy was cleared').toBe('cover')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('replaces a list wholesale, because that is what removing a sort means', () => {
|
|
76
|
+
const sort: Sort = { propertyKey: 'n', direction: 'asc' }
|
|
77
|
+
expect(mergeConfig(config({ sorts: [sort] }), { sorts: [] }).sorts).toEqual([])
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('visiblePropertiesOf', () => {
|
|
82
|
+
const database = {
|
|
83
|
+
properties: [
|
|
84
|
+
property('b', { position: 'b' }),
|
|
85
|
+
property('a', { position: 'a' }),
|
|
86
|
+
property('c', { position: 'c', hidden: true }),
|
|
87
|
+
],
|
|
88
|
+
} as Database
|
|
89
|
+
|
|
90
|
+
it('means every non-hidden column, in position order, when nobody has chosen', () => {
|
|
91
|
+
const view = { config: config() } as never
|
|
92
|
+
expect(visiblePropertiesOf(database, view).map((p) => p.key)).toEqual(['a', 'b'])
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('draws nothing but what was chosen once somebody has', () => {
|
|
96
|
+
const view = { config: config({ visibleProperties: ['b'] }) } as never
|
|
97
|
+
expect(visiblePropertiesOf(database, view).map((p) => p.key)).toEqual(['b'])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('falls back to every column when there is no view yet', () => {
|
|
101
|
+
expect(visiblePropertiesOf(database, null).map((p) => p.key)).toEqual(['a', 'b'])
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('columnTemplate', () => {
|
|
106
|
+
it('gives the title the flexible track and every other column its stored width', () => {
|
|
107
|
+
expect(columnTemplate([property('a'), property('b')], { a: 260 })).toBe(
|
|
108
|
+
'minmax(240px, 1fr) 260px 180px 84px',
|
|
109
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('refuses a width nobody could read', () => {
|
|
113
|
+
expect(columnTemplate([property('a')], { a: 4 })).toContain('90px')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('groupsOf', () => {
|
|
118
|
+
const status = property('status', {
|
|
119
|
+
type: 'status',
|
|
120
|
+
config: {
|
|
121
|
+
options: [
|
|
122
|
+
{ id: 'todo', label: 'To do', colour: 'slate' },
|
|
123
|
+
{ id: 'doing', label: 'Doing', colour: 'accent' },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('keeps the option lanes in declaration order and puts the uncategorised one last', () => {
|
|
129
|
+
const lanes = groupsOf(status, [row('r1', { status: 'doing' }), row('r2', {})])
|
|
130
|
+
expect(lanes.map((l) => l.id)).toEqual(['todo', 'doing', EMPTY_GROUP])
|
|
131
|
+
expect(lanes[1]?.rows.map((r) => r.id)).toEqual(['r1'])
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('puts a row with no value in the uncategorised lane rather than dropping it', () => {
|
|
135
|
+
const lanes = groupsOf(status, [row('r2', { status: null }), row('r3', {})])
|
|
136
|
+
expect(lanes.at(-1)?.rows.map((r) => r.id)).toEqual(['r2', 'r3'])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('puts a row whose value names no option in the uncategorised lane, never nowhere', () => {
|
|
140
|
+
const lanes = groupsOf(status, [row('r4', { status: 'deleted-option' })])
|
|
141
|
+
expect(lanes.at(-1)?.rows.map((r) => r.id)).toEqual(['r4'])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('reads the uncategorised lane as clearing the value', () => {
|
|
145
|
+
expect(groupValue(EMPTY_GROUP)).toBeNull()
|
|
146
|
+
expect(groupValue('doing')).toBe('doing')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Database, Property, Row, SelectOption, View, ViewConfig } from '../../contract/index.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reading and writing a view's configuration.
|
|
5
|
+
*
|
|
6
|
+
* **`updateView` replaces `config` wholesale.** It is one jsonb column and the service writes what
|
|
7
|
+
* it is given, so sending `{ sorts: [...] }` destroys the filters, the grouping, the column widths
|
|
8
|
+
* and the visible properties in the same request. Every write goes through `mergeConfig`, which is
|
|
9
|
+
* the only reason this module exists.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The complete configuration, with the patch applied. Never send a partial one. */
|
|
13
|
+
export function mergeConfig(current: ViewConfig, patch: Partial<ViewConfig>): ViewConfig {
|
|
14
|
+
return {
|
|
15
|
+
filters: patch.filters ?? current.filters,
|
|
16
|
+
filterMode: patch.filterMode ?? current.filterMode,
|
|
17
|
+
sorts: patch.sorts ?? current.sorts,
|
|
18
|
+
groupBy: patch.groupBy !== undefined ? patch.groupBy : current.groupBy,
|
|
19
|
+
dateProperty: patch.dateProperty !== undefined ? patch.dateProperty : current.dateProperty,
|
|
20
|
+
visibleProperties:
|
|
21
|
+
patch.visibleProperties !== undefined ? patch.visibleProperties : current.visibleProperties,
|
|
22
|
+
columnWidths: patch.columnWidths ?? current.columnWidths,
|
|
23
|
+
cardSize: patch.cardSize ?? current.cardSize,
|
|
24
|
+
coverProperty: patch.coverProperty !== undefined ? patch.coverProperty : current.coverProperty,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The columns a view draws, in position order.
|
|
30
|
+
*
|
|
31
|
+
* `visibleProperties: null` means "every column that is not hidden" rather than "none" — a view
|
|
32
|
+
* created before anybody chose has no list, and reading null as an empty list draws a table with no
|
|
33
|
+
* columns at all.
|
|
34
|
+
*/
|
|
35
|
+
export function visiblePropertiesOf(database: Database, view: View | null): Property[] {
|
|
36
|
+
const ordered = [...database.properties].sort((a, b) => (a.position < b.position ? -1 : 1))
|
|
37
|
+
const chosen = view?.config.visibleProperties ?? null
|
|
38
|
+
if (!chosen) return ordered.filter((p) => !p.hidden)
|
|
39
|
+
return ordered.filter((p) => chosen.includes(p.key))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every column, hidden ones included, in position order — what the properties editor lists. */
|
|
43
|
+
export const orderedProperties = (database: Database): Property[] =>
|
|
44
|
+
[...database.properties].sort((a, b) => (a.position < b.position ? -1 : 1))
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_COLUMN_WIDTH = 180
|
|
47
|
+
export const MIN_COLUMN_WIDTH = 90
|
|
48
|
+
export const MAX_COLUMN_WIDTH = 640
|
|
49
|
+
/** The title column absorbs the extra width — DESIGN.md §2.7: a working view fills. */
|
|
50
|
+
export const TITLE_COLUMN = 'minmax(240px, 1fr)'
|
|
51
|
+
/** Room for the row's hover actions, which sit outside the last cell. */
|
|
52
|
+
export const ACTIONS_COLUMN = '84px'
|
|
53
|
+
|
|
54
|
+
/** The grid template for a table: title, then each visible column at its stored or default width. */
|
|
55
|
+
export function columnTemplate(properties: Property[], widths: Record<string, number>): string {
|
|
56
|
+
const cols = properties.map((p) => `${clampWidth(widths[p.key] ?? DEFAULT_COLUMN_WIDTH)}px`)
|
|
57
|
+
return [TITLE_COLUMN, ...cols, ACTIONS_COLUMN].join(' ')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const clampWidth = (px: number): number =>
|
|
61
|
+
Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, Math.round(px)))
|
|
62
|
+
|
|
63
|
+
/** The minimum width the table needs before it starts scrolling inside its own wrapper. */
|
|
64
|
+
export function tableMinWidth(properties: Property[], widths: Record<string, number>): number {
|
|
65
|
+
const cols = properties.reduce((total, p) => total + clampWidth(widths[p.key] ?? DEFAULT_COLUMN_WIDTH), 0)
|
|
66
|
+
return 240 + cols + 84 + (properties.length + 2) * 12
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The lane a row with no value falls into. Not a valid option id — options are at least one char. */
|
|
70
|
+
export const EMPTY_GROUP = '__none__'
|
|
71
|
+
|
|
72
|
+
export interface Lane {
|
|
73
|
+
id: string
|
|
74
|
+
option: SelectOption | null
|
|
75
|
+
rows: Row[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The lanes of a board, in the order the column's options are declared.
|
|
80
|
+
*
|
|
81
|
+
* The uncategorised lane is last and always present: a board that hides it hides the rows nobody
|
|
82
|
+
* has triaged yet, which are the ones the board exists to surface.
|
|
83
|
+
*/
|
|
84
|
+
export function groupsOf(property: Property | null, rows: Row[]): Lane[] {
|
|
85
|
+
const options = property?.config.options ?? []
|
|
86
|
+
const lanes: Lane[] = options.map((option) => ({ id: option.id, option, rows: [] }))
|
|
87
|
+
const none: Lane = { id: EMPTY_GROUP, option: null, rows: [] }
|
|
88
|
+
|
|
89
|
+
for (const row of rows) {
|
|
90
|
+
const raw = property ? row.props[property.key] : null
|
|
91
|
+
const value = Array.isArray(raw) ? raw[0] : raw
|
|
92
|
+
const lane = value == null ? undefined : lanes.find((l) => l.id === String(value))
|
|
93
|
+
;(lane ?? none).rows.push(row)
|
|
94
|
+
}
|
|
95
|
+
return [...lanes, none]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The value to write when a card is dropped in a lane; the uncategorised lane means "no value". */
|
|
99
|
+
export const groupValue = (laneId: string): string | null => (laneId === EMPTY_GROUP ? null : laneId)
|
|
100
|
+
|
|
101
|
+
/** The status bands, in workflow order, so a status menu is grouped rather than alphabetical. */
|
|
102
|
+
export const STATUS_GROUPS = ['todo', 'doing', 'done'] as const
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The formula language.
|
|
3
|
+
*
|
|
4
|
+
* The first test is the one that matters: a formula is text a workspace member types and the server
|
|
5
|
+
* evaluates. If it ever reaches `eval` or `new Function`, everything else here is irrelevant.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from 'vitest'
|
|
8
|
+
import { evaluateFormula, type FormulaValue, formulaDependencies, parseFormula } from './formula.js'
|
|
9
|
+
|
|
10
|
+
const run = (src: string, props: Record<string, FormulaValue> = {}) =>
|
|
11
|
+
evaluateFormula(parseFormula(src), { prop: (n) => props[n] ?? null })
|
|
12
|
+
|
|
13
|
+
describe('what a formula cannot do', () => {
|
|
14
|
+
it('refuses anything that is not in the function table', () => {
|
|
15
|
+
for (const attack of [
|
|
16
|
+
'constructor("return 1")()',
|
|
17
|
+
'process.exit(1)',
|
|
18
|
+
'require("fs")',
|
|
19
|
+
'globalThis',
|
|
20
|
+
'eval("1")',
|
|
21
|
+
]) {
|
|
22
|
+
expect(() => parseFormula(attack), attack).toThrow()
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('refuses a formula that nests beyond the limit rather than blowing the stack', () => {
|
|
27
|
+
const deep = `${'abs('.repeat(200)}1${')'.repeat(200)}`
|
|
28
|
+
expect(() => run(deep)).toThrow(/nests too deeply/i)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('refuses an unterminated string and a stray character', () => {
|
|
32
|
+
expect(() => parseFormula('concat("oops')).toThrow()
|
|
33
|
+
expect(() => parseFormula('1 § 2')).toThrow()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('arithmetic and precedence', () => {
|
|
38
|
+
it('binds multiplication tighter than addition', () => {
|
|
39
|
+
expect(run('1 + 2 * 3')).toBe(7)
|
|
40
|
+
expect(run('(1 + 2) * 3')).toBe(9)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('treats ^ as right-associative', () => {
|
|
44
|
+
expect(run('2 ^ 3 ^ 2')).toBe(512)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('gives a blank rather than Infinity when dividing by nothing', () => {
|
|
48
|
+
expect(run('1 / 0')).toBeNull()
|
|
49
|
+
expect(run('1 % 0')).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('negates', () => {
|
|
53
|
+
expect(run('-3 + 1')).toBe(-2)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('text and numbers together', () => {
|
|
58
|
+
it('concatenates when either side is text, and adds when neither is', () => {
|
|
59
|
+
expect(run('"a" + 1')).toBe('a1')
|
|
60
|
+
expect(run('1 + 1')).toBe(2)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('reads a property by the name somebody typed', () => {
|
|
64
|
+
expect(run('prop("Estimate") * 2', { Estimate: 4 })).toBe(8)
|
|
65
|
+
expect(run('prop("Nothing") + 1')).toBe(1)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('logic', () => {
|
|
70
|
+
it('short-circuits, so the right side of a false && is never evaluated', () => {
|
|
71
|
+
let touched = false
|
|
72
|
+
const ast = parseFormula('false && prop("x")')
|
|
73
|
+
evaluateFormula(ast, {
|
|
74
|
+
prop: () => {
|
|
75
|
+
touched = true
|
|
76
|
+
return 1
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
expect(touched).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('chooses with if()', () => {
|
|
83
|
+
expect(run('if(prop("Done"), "yes", "no")', { Done: true })).toBe('yes')
|
|
84
|
+
expect(run('if(prop("Done"), "yes", "no")', { Done: false })).toBe('no')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('treats = and == the same, because both are what people type', () => {
|
|
88
|
+
expect(run('1 = 1')).toBe(true)
|
|
89
|
+
expect(run('1 == 1')).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('functions', () => {
|
|
94
|
+
it('does text', () => {
|
|
95
|
+
expect(run('upper(concat("ab", "c"))')).toBe('ABC')
|
|
96
|
+
expect(run('length("hello")')).toBe(5)
|
|
97
|
+
expect(run('replace("a-b", "-", "+")')).toBe('a+b')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('does numbers', () => {
|
|
101
|
+
expect(run('round(3.14159, 2)')).toBe(3.14)
|
|
102
|
+
expect(run('max(1, 9, 3)')).toBe(9)
|
|
103
|
+
expect(run('sum(1, 2, 3)')).toBe(6)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('counts days between two dates', () => {
|
|
107
|
+
expect(run('dateBetween("2026-01-01", "2026-01-11")')).toBe(10)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('finds a function whatever case it was typed in', () => {
|
|
111
|
+
// Names are camelCase for readability but a person typing one should not have to remember
|
|
112
|
+
// that. Lowercasing at the lookup rather than at the key is what keeps them reachable.
|
|
113
|
+
expect(run('datebetween("2026-01-01", "2026-01-03")')).toBe(2)
|
|
114
|
+
expect(run('DATEBETWEEN("2026-01-01", "2026-01-03")')).toBe(2)
|
|
115
|
+
expect(run('toNumber("42") + 1')).toBe(43)
|
|
116
|
+
expect(run('isEmpty(prop("nothing"))')).toBe(true)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('the AST is data, not a promise', () => {
|
|
121
|
+
it('does not make an if node thenable', async () => {
|
|
122
|
+
// An object with a `then` property is thenable: `await` calls it as a promise. This node's
|
|
123
|
+
// `then` would have been an AST node rather than a function, so returning one from an async
|
|
124
|
+
// function would silently resolve to the wrong value — with no error where the mistake is.
|
|
125
|
+
const ast = parseFormula('if(true, 1, 2)')
|
|
126
|
+
expect(Object.hasOwn(ast, 'then'), 'an if node must never carry a `then`').toBe(false)
|
|
127
|
+
|
|
128
|
+
const wrapped = await (async () => ast)()
|
|
129
|
+
expect(wrapped, 'awaiting the node changed it into something else').toEqual(ast)
|
|
130
|
+
expect(await Promise.resolve(ast)).toEqual(ast)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('dependencies', () => {
|
|
135
|
+
it('reports every property a formula reads, so a change recomputes only what depends on it', () => {
|
|
136
|
+
const ast = parseFormula('if(prop("A") > 1, prop("B"), prop("C") + prop("A"))')
|
|
137
|
+
expect([...formulaDependencies(ast)].sort()).toEqual(['A', 'B', 'C'])
|
|
138
|
+
})
|
|
139
|
+
})
|