@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,116 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Avatar, DropdownMenu, type MenuItem } from '@kernhq/ui'
|
|
3
|
+
import type { PropertyConfig } from '../../../contract/index.js'
|
|
4
|
+
import type { Person } from '../../core-api.js'
|
|
5
|
+
import { t } from '../../i18n.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One or more people, stored as user ids.
|
|
9
|
+
*
|
|
10
|
+
* The face comes before the name in the menu, because a person is recognised by their picture
|
|
11
|
+
* faster than by their name — which is what `MenuItem`'s `avatar` field is for.
|
|
12
|
+
*/
|
|
13
|
+
interface Props {
|
|
14
|
+
value: unknown
|
|
15
|
+
name: string
|
|
16
|
+
config: PropertyConfig
|
|
17
|
+
people: Person[]
|
|
18
|
+
editable: boolean
|
|
19
|
+
reason?: string
|
|
20
|
+
onchange: (value: unknown) => void
|
|
21
|
+
}
|
|
22
|
+
const { value, name, config, people, editable, reason, onchange }: Props = $props()
|
|
23
|
+
|
|
24
|
+
const many = $derived(config.multiple !== false)
|
|
25
|
+
const chosen = $derived(
|
|
26
|
+
value == null ? [] : (Array.isArray(value) ? value : [value]).map(String).filter((v) => v !== ''),
|
|
27
|
+
)
|
|
28
|
+
const personFor = (id: string) => people.find((p) => p.id === id) ?? null
|
|
29
|
+
|
|
30
|
+
const commit = (next: string[]) => onchange(many ? next : (next[0] ?? null))
|
|
31
|
+
const toggle = (id: string, on: boolean) => {
|
|
32
|
+
if (many) commit(on ? [...chosen.filter((v) => v !== id), id] : chosen.filter((v) => v !== id))
|
|
33
|
+
else commit(on ? [id] : [])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const items = $derived.by(() => {
|
|
37
|
+
const list: MenuItem[] = people.map((person) => ({
|
|
38
|
+
type: 'checkbox' as const,
|
|
39
|
+
id: person.id,
|
|
40
|
+
label: person.name,
|
|
41
|
+
avatar: { id: person.id, name: person.name, src: person.avatarUrl },
|
|
42
|
+
checked: chosen.includes(person.id),
|
|
43
|
+
onCheckedChange: (on: boolean) => toggle(person.id, on),
|
|
44
|
+
}))
|
|
45
|
+
if (list.length === 0) return [{ type: 'label' as const, label: t('db_people_none') }]
|
|
46
|
+
if (chosen.length > 0)
|
|
47
|
+
list.push(
|
|
48
|
+
{ type: 'separator' },
|
|
49
|
+
{ id: 'clear', label: t('db_select_clear'), icon: 'x', onSelect: () => commit([]) },
|
|
50
|
+
)
|
|
51
|
+
return list
|
|
52
|
+
})
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
{#snippet faces()}
|
|
56
|
+
{#if chosen.length === 0}
|
|
57
|
+
<span class="muted">{t('db_cell_empty')}</span>
|
|
58
|
+
{:else}
|
|
59
|
+
{#each chosen as id (id)}
|
|
60
|
+
<span class="who">
|
|
61
|
+
<Avatar id={id} name={personFor(id)?.name ?? null} src={personFor(id)?.avatarUrl ?? null} size={20} />
|
|
62
|
+
<span class="nm">{personFor(id)?.name ?? id}</span>
|
|
63
|
+
</span>
|
|
64
|
+
{/each}
|
|
65
|
+
{/if}
|
|
66
|
+
{/snippet}
|
|
67
|
+
|
|
68
|
+
{#if editable}
|
|
69
|
+
<DropdownMenu {items} align="start">
|
|
70
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
71
|
+
<button {...props} type="button" class="cell-trigger" aria-label={name}>{@render faces()}</button>
|
|
72
|
+
{/snippet}
|
|
73
|
+
</DropdownMenu>
|
|
74
|
+
{:else}
|
|
75
|
+
<span class="cell-trigger static" title={reason}>{@render faces()}</span>
|
|
76
|
+
{/if}
|
|
77
|
+
|
|
78
|
+
<style>
|
|
79
|
+
.cell-trigger {
|
|
80
|
+
display: inline-flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: 6px;
|
|
83
|
+
max-width: 100%;
|
|
84
|
+
min-height: 26px;
|
|
85
|
+
padding: 2px 6px;
|
|
86
|
+
margin-inline-start: -6px;
|
|
87
|
+
border: 0;
|
|
88
|
+
border-radius: var(--kern-r-sm);
|
|
89
|
+
background: none;
|
|
90
|
+
color: inherit;
|
|
91
|
+
font: inherit;
|
|
92
|
+
font-size: 13px;
|
|
93
|
+
text-align: start;
|
|
94
|
+
overflow: hidden;
|
|
95
|
+
}
|
|
96
|
+
button.cell-trigger:hover {
|
|
97
|
+
background: var(--kern-surface-active);
|
|
98
|
+
}
|
|
99
|
+
.cell-trigger.static {
|
|
100
|
+
cursor: default;
|
|
101
|
+
}
|
|
102
|
+
.who {
|
|
103
|
+
display: inline-flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
gap: 5px;
|
|
106
|
+
min-width: 0;
|
|
107
|
+
}
|
|
108
|
+
.nm {
|
|
109
|
+
overflow: hidden;
|
|
110
|
+
text-overflow: ellipsis;
|
|
111
|
+
white-space: nowrap;
|
|
112
|
+
}
|
|
113
|
+
.muted {
|
|
114
|
+
color: var(--kern-ink-450);
|
|
115
|
+
}
|
|
116
|
+
</style>
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Icon, IconButton, Popover, SearchBox, Spinner } from '@kernhq/ui'
|
|
3
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import type { PropertyConfig, RowRef } from '../../../contract/index.js'
|
|
5
|
+
import { getQuireApi } from '../../api-instance.js'
|
|
6
|
+
import { t } from '../../i18n.js'
|
|
7
|
+
import { quireKeys } from '../../query.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Rows linked from another database.
|
|
11
|
+
*
|
|
12
|
+
* A relation stores page ids and nothing else, so both jobs this cell has — drawing the links it
|
|
13
|
+
* already holds and finding the next one — are `databases.lookup` calls against the database on the
|
|
14
|
+
* other side. Without the first, the column is a list of uuids.
|
|
15
|
+
*
|
|
16
|
+
* The write goes through `updateRow`'s props like every other cell; the server splits relation keys
|
|
17
|
+
* out and routes them to `setRelation`, so the join table and the mirror in `props` cannot diverge
|
|
18
|
+
* whichever surface did the editing.
|
|
19
|
+
*/
|
|
20
|
+
interface Props {
|
|
21
|
+
value: unknown
|
|
22
|
+
name: string
|
|
23
|
+
config: PropertyConfig
|
|
24
|
+
workspaceId: string
|
|
25
|
+
editable: boolean
|
|
26
|
+
reason?: string
|
|
27
|
+
onchange: (value: unknown) => void
|
|
28
|
+
}
|
|
29
|
+
const { value, name, config, workspaceId, editable, reason, onchange }: Props = $props()
|
|
30
|
+
|
|
31
|
+
const api = getQuireApi()
|
|
32
|
+
|
|
33
|
+
const ids = $derived(
|
|
34
|
+
value == null ? [] : (Array.isArray(value) ? value : [value]).map(String).filter((v) => v !== ''),
|
|
35
|
+
)
|
|
36
|
+
const targetId = $derived(config.relationDatabaseId ?? null)
|
|
37
|
+
|
|
38
|
+
let open = $state(false)
|
|
39
|
+
let term = $state('')
|
|
40
|
+
|
|
41
|
+
/** The names of what is already linked. Keyed by the ids so it re-resolves when they change. */
|
|
42
|
+
const linked = createQuery(() => ({
|
|
43
|
+
queryKey: [...quireKeys.lookup(workspaceId, targetId ?? '', 'ids'), ids.join(',')],
|
|
44
|
+
enabled: Boolean(workspaceId && targetId) && ids.length > 0,
|
|
45
|
+
queryFn: () => api.databases.lookup({ workspaceId, databaseId: targetId!, ids, query: '', limit: 100 }),
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
const search = createQuery(() => ({
|
|
49
|
+
queryKey: quireKeys.lookup(workspaceId, targetId ?? '', term),
|
|
50
|
+
enabled: Boolean(workspaceId && targetId) && open,
|
|
51
|
+
queryFn: () =>
|
|
52
|
+
api.databases.lookup({ workspaceId, databaseId: targetId!, query: term, ids: [], limit: 25 }),
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
/** An id whose row has been deleted still has to draw as something. */
|
|
56
|
+
const chips = $derived<RowRef[]>(
|
|
57
|
+
ids.map((id) => (linked.data ?? []).find((r) => r.id === id) ?? { id, title: t('untitled'), icon: null }),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const results = $derived((search.data ?? []).filter((r) => !ids.includes(r.id)))
|
|
61
|
+
|
|
62
|
+
const link = (id: string) => {
|
|
63
|
+
onchange([...ids, id])
|
|
64
|
+
term = ''
|
|
65
|
+
}
|
|
66
|
+
const unlink = (id: string) => onchange(ids.filter((v) => v !== id))
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<span class="rel">
|
|
70
|
+
{#each chips as chip (chip.id)}
|
|
71
|
+
<span class="chip">
|
|
72
|
+
<span class="nm">{chip.title.trim() || t('untitled')}</span>
|
|
73
|
+
{#if editable}
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
class="x"
|
|
77
|
+
aria-label={t('db_relation_unlink', { title: chip.title.trim() || t('untitled') })}
|
|
78
|
+
onclick={() => unlink(chip.id)}
|
|
79
|
+
>
|
|
80
|
+
<Icon name="x" size={11} strokeWidth={2} />
|
|
81
|
+
</button>
|
|
82
|
+
{/if}
|
|
83
|
+
</span>
|
|
84
|
+
{/each}
|
|
85
|
+
|
|
86
|
+
{#if ids.length === 0 && !editable}
|
|
87
|
+
<span class="muted" title={reason}>{t('db_cell_empty')}</span>
|
|
88
|
+
{/if}
|
|
89
|
+
|
|
90
|
+
{#if editable}
|
|
91
|
+
{#if !targetId}
|
|
92
|
+
<!-- Not disabled-with-no-reason: the column has nothing on the other side to link to yet. -->
|
|
93
|
+
<span class="muted" title={t('db_relation_untargeted')}>{t('db_relation_untargeted')}</span>
|
|
94
|
+
{:else}
|
|
95
|
+
<Popover bind:open align="start" width="300px" onOpenChange={(o) => !o && (term = '')}>
|
|
96
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
97
|
+
<button {...props} type="button" class="add" aria-label={t('db_relation_link')}>
|
|
98
|
+
<Icon name="plus" size={13} strokeWidth={1.9} />
|
|
99
|
+
</button>
|
|
100
|
+
{/snippet}
|
|
101
|
+
<div class="picker">
|
|
102
|
+
<SearchBox
|
|
103
|
+
bind:value={term}
|
|
104
|
+
placeholder={t('db_relation_search')}
|
|
105
|
+
label={t('db_relation_search')}
|
|
106
|
+
height={32}
|
|
107
|
+
/>
|
|
108
|
+
{#if search.isLoading}
|
|
109
|
+
<div class="state"><Spinner size={16} /></div>
|
|
110
|
+
{:else if search.isError}
|
|
111
|
+
<p class="state">{t('common.error')}</p>
|
|
112
|
+
{:else if results.length === 0}
|
|
113
|
+
<p class="state">{t('db_relation_none')}</p>
|
|
114
|
+
{:else}
|
|
115
|
+
<ul class="results">
|
|
116
|
+
{#each results as row (row.id)}
|
|
117
|
+
<li>
|
|
118
|
+
<button type="button" class="result" onclick={() => link(row.id)}>
|
|
119
|
+
<Icon name="file-text" size={13} strokeWidth={1.7} />
|
|
120
|
+
<span class="nm">{row.title.trim() || t('untitled')}</span>
|
|
121
|
+
</button>
|
|
122
|
+
</li>
|
|
123
|
+
{/each}
|
|
124
|
+
</ul>
|
|
125
|
+
{/if}
|
|
126
|
+
</div>
|
|
127
|
+
</Popover>
|
|
128
|
+
{/if}
|
|
129
|
+
{/if}
|
|
130
|
+
</span>
|
|
131
|
+
|
|
132
|
+
<style>
|
|
133
|
+
.rel {
|
|
134
|
+
display: inline-flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
gap: 4px;
|
|
137
|
+
min-width: 0;
|
|
138
|
+
overflow: hidden;
|
|
139
|
+
}
|
|
140
|
+
.chip {
|
|
141
|
+
display: inline-flex;
|
|
142
|
+
align-items: center;
|
|
143
|
+
gap: 2px;
|
|
144
|
+
max-width: 160px;
|
|
145
|
+
padding-inline: 7px 2px;
|
|
146
|
+
padding-block: 2px;
|
|
147
|
+
border-radius: var(--kern-r-md);
|
|
148
|
+
background: var(--kern-surface-chip);
|
|
149
|
+
color: var(--kern-ink-550);
|
|
150
|
+
font-size: 12px;
|
|
151
|
+
}
|
|
152
|
+
.nm {
|
|
153
|
+
overflow: hidden;
|
|
154
|
+
text-overflow: ellipsis;
|
|
155
|
+
white-space: nowrap;
|
|
156
|
+
}
|
|
157
|
+
.x,
|
|
158
|
+
.add {
|
|
159
|
+
flex: none;
|
|
160
|
+
display: inline-grid;
|
|
161
|
+
place-items: center;
|
|
162
|
+
width: 24px;
|
|
163
|
+
height: 24px;
|
|
164
|
+
border: 0;
|
|
165
|
+
border-radius: var(--kern-r-sm);
|
|
166
|
+
background: none;
|
|
167
|
+
color: var(--kern-ink-450);
|
|
168
|
+
}
|
|
169
|
+
.x:hover,
|
|
170
|
+
.add:hover {
|
|
171
|
+
background: var(--kern-surface-active);
|
|
172
|
+
color: var(--kern-ink-900);
|
|
173
|
+
}
|
|
174
|
+
.picker {
|
|
175
|
+
padding: 10px;
|
|
176
|
+
display: flex;
|
|
177
|
+
flex-direction: column;
|
|
178
|
+
gap: 8px;
|
|
179
|
+
}
|
|
180
|
+
.results {
|
|
181
|
+
list-style: none;
|
|
182
|
+
margin: 0;
|
|
183
|
+
padding: 0;
|
|
184
|
+
display: flex;
|
|
185
|
+
flex-direction: column;
|
|
186
|
+
gap: 1px;
|
|
187
|
+
max-height: 240px;
|
|
188
|
+
overflow-y: auto;
|
|
189
|
+
}
|
|
190
|
+
.result {
|
|
191
|
+
display: flex;
|
|
192
|
+
align-items: center;
|
|
193
|
+
gap: 7px;
|
|
194
|
+
width: 100%;
|
|
195
|
+
min-height: 30px;
|
|
196
|
+
padding: 4px 8px;
|
|
197
|
+
border: 0;
|
|
198
|
+
border-radius: var(--kern-r-md);
|
|
199
|
+
background: none;
|
|
200
|
+
color: var(--kern-ink-700);
|
|
201
|
+
font: inherit;
|
|
202
|
+
font-size: 13px;
|
|
203
|
+
text-align: start;
|
|
204
|
+
}
|
|
205
|
+
.result:hover {
|
|
206
|
+
background: var(--kern-surface-popover-hover);
|
|
207
|
+
}
|
|
208
|
+
.state {
|
|
209
|
+
margin: 0;
|
|
210
|
+
padding: 12px 4px;
|
|
211
|
+
text-align: center;
|
|
212
|
+
font-size: 12.5px;
|
|
213
|
+
color: var(--kern-ink-450);
|
|
214
|
+
}
|
|
215
|
+
.muted {
|
|
216
|
+
color: var(--kern-ink-450);
|
|
217
|
+
font-size: 13px;
|
|
218
|
+
overflow: hidden;
|
|
219
|
+
text-overflow: ellipsis;
|
|
220
|
+
white-space: nowrap;
|
|
221
|
+
}
|
|
222
|
+
</style>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { DropdownMenu, type MenuItem } from '@kernhq/ui'
|
|
3
|
+
import type { PropertyConfig, PropertyType } from '../../../contract/index.js'
|
|
4
|
+
import { t } from '../../i18n.js'
|
|
5
|
+
import OptionChip from '../OptionChip.svelte'
|
|
6
|
+
import { STATUS_GROUPS } from '../view-config.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* select, multi_select and status.
|
|
10
|
+
*
|
|
11
|
+
* A menu rather than an inline list, because the table cell clips (`.ktd` is `overflow: hidden`)
|
|
12
|
+
* and `DropdownMenu` portals out of it. A status groups its options into the workflow bands it
|
|
13
|
+
* declares, so "Done" is not sitting between "Blocked" and "Doing" in alphabetical order.
|
|
14
|
+
*/
|
|
15
|
+
interface Props {
|
|
16
|
+
value: unknown
|
|
17
|
+
name: string
|
|
18
|
+
type: PropertyType
|
|
19
|
+
config: PropertyConfig
|
|
20
|
+
editable: boolean
|
|
21
|
+
reason?: string
|
|
22
|
+
onchange: (value: unknown) => void
|
|
23
|
+
}
|
|
24
|
+
const { value, name, type, config, editable, reason, onchange }: Props = $props()
|
|
25
|
+
|
|
26
|
+
const many = $derived(type === 'multi_select')
|
|
27
|
+
const options = $derived(config.options ?? [])
|
|
28
|
+
|
|
29
|
+
const chosen = $derived.by(() => {
|
|
30
|
+
if (value == null) return [] as string[]
|
|
31
|
+
return (Array.isArray(value) ? value : [value]).map(String).filter((v) => v !== '')
|
|
32
|
+
})
|
|
33
|
+
const optionFor = (id: string) => options.find((o) => o.id === id) ?? null
|
|
34
|
+
|
|
35
|
+
const commit = (next: string[]) => onchange(many ? next : (next[0] ?? null))
|
|
36
|
+
|
|
37
|
+
const toggle = (id: string, on: boolean) => {
|
|
38
|
+
if (many) commit(on ? [...chosen.filter((v) => v !== id), id] : chosen.filter((v) => v !== id))
|
|
39
|
+
else commit(on ? [id] : [])
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Status options in band order; everything else in the order the column declares them. */
|
|
43
|
+
const ordered = $derived.by(() => {
|
|
44
|
+
if (type !== 'status') return options.map((option) => ({ option, band: null as string | null }))
|
|
45
|
+
const out: { option: (typeof options)[number]; band: string | null }[] = []
|
|
46
|
+
for (const band of STATUS_GROUPS)
|
|
47
|
+
for (const option of options.filter((o) => o.group === band)) out.push({ option, band })
|
|
48
|
+
for (const option of options.filter((o) => !o.group || !STATUS_GROUPS.includes(o.group)))
|
|
49
|
+
out.push({ option, band: null })
|
|
50
|
+
return out
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const items = $derived.by(() => {
|
|
54
|
+
const list: MenuItem[] = []
|
|
55
|
+
let band: string | null | undefined
|
|
56
|
+
for (const entry of ordered) {
|
|
57
|
+
if (type === 'status' && entry.band && entry.band !== band) {
|
|
58
|
+
list.push({ type: 'label', label: t(`db_status_${entry.band}`) })
|
|
59
|
+
band = entry.band
|
|
60
|
+
}
|
|
61
|
+
list.push({
|
|
62
|
+
type: 'checkbox',
|
|
63
|
+
id: entry.option.id,
|
|
64
|
+
label: entry.option.label,
|
|
65
|
+
checked: chosen.includes(entry.option.id),
|
|
66
|
+
onCheckedChange: (on: boolean) => toggle(entry.option.id, on),
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
if (list.length === 0) list.push({ type: 'label', label: t('db_options_none') })
|
|
70
|
+
else if (chosen.length > 0)
|
|
71
|
+
list.push(
|
|
72
|
+
{ type: 'separator' },
|
|
73
|
+
{ id: 'clear', label: t('db_select_clear'), icon: 'x', onSelect: () => commit([]) },
|
|
74
|
+
)
|
|
75
|
+
return list
|
|
76
|
+
})
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
{#if editable}
|
|
80
|
+
<DropdownMenu items={items} align="start">
|
|
81
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
82
|
+
<button {...props} type="button" class="cell-trigger" aria-label={name}>
|
|
83
|
+
{#if chosen.length === 0}
|
|
84
|
+
<span class="muted">{t('db_cell_empty')}</span>
|
|
85
|
+
{:else}
|
|
86
|
+
{#each chosen as id (id)}
|
|
87
|
+
<OptionChip option={optionFor(id)} label={optionFor(id) ? undefined : id} compact />
|
|
88
|
+
{/each}
|
|
89
|
+
{/if}
|
|
90
|
+
</button>
|
|
91
|
+
{/snippet}
|
|
92
|
+
</DropdownMenu>
|
|
93
|
+
{:else}
|
|
94
|
+
<span class="cell-trigger static" title={reason}>
|
|
95
|
+
{#if chosen.length === 0}
|
|
96
|
+
<span class="muted">{t('db_cell_empty')}</span>
|
|
97
|
+
{:else}
|
|
98
|
+
{#each chosen as id (id)}
|
|
99
|
+
<OptionChip option={optionFor(id)} label={optionFor(id) ? undefined : id} compact />
|
|
100
|
+
{/each}
|
|
101
|
+
{/if}
|
|
102
|
+
</span>
|
|
103
|
+
{/if}
|
|
104
|
+
|
|
105
|
+
<style>
|
|
106
|
+
.cell-trigger {
|
|
107
|
+
display: inline-flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
flex-wrap: nowrap;
|
|
110
|
+
gap: 4px;
|
|
111
|
+
max-width: 100%;
|
|
112
|
+
min-height: 26px;
|
|
113
|
+
padding: 2px 6px;
|
|
114
|
+
margin-inline-start: -6px;
|
|
115
|
+
border: 0;
|
|
116
|
+
border-radius: var(--kern-r-sm);
|
|
117
|
+
background: none;
|
|
118
|
+
color: inherit;
|
|
119
|
+
font: inherit;
|
|
120
|
+
font-size: 13px;
|
|
121
|
+
text-align: start;
|
|
122
|
+
overflow: hidden;
|
|
123
|
+
}
|
|
124
|
+
button.cell-trigger:hover {
|
|
125
|
+
background: var(--kern-surface-active);
|
|
126
|
+
}
|
|
127
|
+
.cell-trigger.static {
|
|
128
|
+
cursor: default;
|
|
129
|
+
}
|
|
130
|
+
.muted {
|
|
131
|
+
color: var(--kern-ink-450);
|
|
132
|
+
}
|
|
133
|
+
</style>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { t } from '../../i18n.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A bare input that only looks like a control while somebody is in it (DESIGN.md §3.13).
|
|
6
|
+
*
|
|
7
|
+
* Committed on blur and on Enter, never per keystroke: each edit is its own mutation, and a request
|
|
8
|
+
* per character is both slow and impossible to reason about when one of them fails.
|
|
9
|
+
*/
|
|
10
|
+
interface Props {
|
|
11
|
+
value: unknown
|
|
12
|
+
name: string
|
|
13
|
+
editable: boolean
|
|
14
|
+
reason?: string
|
|
15
|
+
onchange: (value: unknown) => void
|
|
16
|
+
}
|
|
17
|
+
const { value, name, editable, reason, onchange }: Props = $props()
|
|
18
|
+
|
|
19
|
+
const text = $derived(value == null ? '' : String(value))
|
|
20
|
+
|
|
21
|
+
/** An empty field is a cleared cell, and `null` is how the API says that. */
|
|
22
|
+
const commit = (next: string) => {
|
|
23
|
+
if (next === text) return
|
|
24
|
+
onchange(next === '' ? null : next)
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
{#if editable}
|
|
29
|
+
<input
|
|
30
|
+
class="cell-input"
|
|
31
|
+
value={text}
|
|
32
|
+
aria-label={name}
|
|
33
|
+
placeholder={t('db_cell_empty')}
|
|
34
|
+
onblur={(e) => commit(e.currentTarget.value)}
|
|
35
|
+
onkeydown={(e) => {
|
|
36
|
+
if (e.key === 'Enter') e.currentTarget.blur()
|
|
37
|
+
if (e.key === 'Escape') {
|
|
38
|
+
e.currentTarget.value = text
|
|
39
|
+
e.currentTarget.blur()
|
|
40
|
+
}
|
|
41
|
+
}}
|
|
42
|
+
/>
|
|
43
|
+
{:else}
|
|
44
|
+
<span class="cell-static" title={reason}>
|
|
45
|
+
{#if text}{text}{:else}<span class="muted">{t('db_cell_empty')}</span>{/if}
|
|
46
|
+
</span>
|
|
47
|
+
{/if}
|
|
48
|
+
|
|
49
|
+
<style>
|
|
50
|
+
.cell-input {
|
|
51
|
+
width: 100%;
|
|
52
|
+
min-width: 0;
|
|
53
|
+
min-height: 26px;
|
|
54
|
+
padding: 2px 6px;
|
|
55
|
+
margin-inline-start: -6px;
|
|
56
|
+
border: 1px solid transparent;
|
|
57
|
+
border-radius: var(--kern-r-sm);
|
|
58
|
+
background: none;
|
|
59
|
+
color: inherit;
|
|
60
|
+
font: inherit;
|
|
61
|
+
font-size: 13px;
|
|
62
|
+
}
|
|
63
|
+
.cell-input:hover {
|
|
64
|
+
background: var(--kern-surface-active);
|
|
65
|
+
}
|
|
66
|
+
.cell-input:focus {
|
|
67
|
+
border-color: var(--kern-border);
|
|
68
|
+
background: var(--kern-surface-raised);
|
|
69
|
+
outline: none;
|
|
70
|
+
}
|
|
71
|
+
.cell-input::placeholder {
|
|
72
|
+
color: var(--kern-ink-350);
|
|
73
|
+
}
|
|
74
|
+
.cell-static {
|
|
75
|
+
min-width: 0;
|
|
76
|
+
overflow: hidden;
|
|
77
|
+
text-overflow: ellipsis;
|
|
78
|
+
white-space: nowrap;
|
|
79
|
+
cursor: default;
|
|
80
|
+
font-size: 13px;
|
|
81
|
+
}
|
|
82
|
+
.muted {
|
|
83
|
+
color: var(--kern-ink-450);
|
|
84
|
+
}
|
|
85
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The colours a select option may wear.
|
|
3
|
+
*
|
|
4
|
+
* Closed on purpose. `SelectOption.colour` is free text in the contract, so painting it directly
|
|
5
|
+
* means one typo renders a chip with no background — and, worse, a colour pair nobody has measured.
|
|
6
|
+
* Every pair here is one the design tokens already tuned for contrast in light and dark; an unknown
|
|
7
|
+
* name falls back to grey rather than to nothing.
|
|
8
|
+
*/
|
|
9
|
+
export interface Tone {
|
|
10
|
+
bg: string
|
|
11
|
+
fg: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const TONES: Record<string, Tone> = {
|
|
15
|
+
grey: { bg: 'var(--kern-surface-chip)', fg: 'var(--kern-ink-550)' },
|
|
16
|
+
slate: { bg: 'var(--kern-slate-tint)', fg: 'var(--kern-slate)' },
|
|
17
|
+
accent: { bg: 'var(--kern-accent-tint)', fg: 'var(--kern-accent-deep)' },
|
|
18
|
+
success: { bg: 'var(--kern-success-tint)', fg: 'var(--kern-success-chip)' },
|
|
19
|
+
warning: { bg: 'var(--kern-warning-tint)', fg: 'var(--kern-warning)' },
|
|
20
|
+
danger: { bg: 'var(--kern-danger-tint)', fg: 'var(--kern-danger)' },
|
|
21
|
+
info: { bg: 'var(--kern-info-tint)', fg: 'var(--kern-info)' },
|
|
22
|
+
purple: { bg: 'var(--kern-purple-tint)', fg: 'var(--kern-purple)' },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const OPTION_COLOURS = Object.keys(TONES)
|
|
26
|
+
|
|
27
|
+
export const toneFor = (colour: string | undefined | null): Tone => TONES[colour ?? 'grey'] ?? TONES.grey!
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The property-type table is what every branch in the database interface reads instead of
|
|
3
|
+
* re-switching on the type. These assertions are about the two ways it can be wrong and nothing
|
|
4
|
+
* will say so: a type with no entry (which draws nothing), and an operator offered for a column
|
|
5
|
+
* that cannot answer it (which silently matches no rows).
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from 'vitest'
|
|
8
|
+
import { PropertyType } from '../../contract/index.js'
|
|
9
|
+
import {
|
|
10
|
+
CREATABLE_TYPES,
|
|
11
|
+
descriptorFor,
|
|
12
|
+
isReadOnly,
|
|
13
|
+
operatorsFor,
|
|
14
|
+
PROPERTY_TYPES,
|
|
15
|
+
VIEW_KINDS,
|
|
16
|
+
} from './property-types.js'
|
|
17
|
+
|
|
18
|
+
describe('the property type table', () => {
|
|
19
|
+
it('describes every type the contract declares', () => {
|
|
20
|
+
for (const type of PropertyType.options) {
|
|
21
|
+
const descriptor = descriptorFor(type)
|
|
22
|
+
expect(descriptor, `${type} has no descriptor, so its column draws nothing`).toBeDefined()
|
|
23
|
+
expect(descriptor.icon.length, `${type} icon`).toBeGreaterThan(0)
|
|
24
|
+
}
|
|
25
|
+
expect(Object.keys(PROPERTY_TYPES).sort()).toEqual([...PropertyType.options].sort())
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('marks exactly the server-written types read-only', () => {
|
|
29
|
+
const readOnly = PropertyType.options.filter((t) => isReadOnly(t)).sort()
|
|
30
|
+
expect(readOnly).toEqual(
|
|
31
|
+
['created_by', 'created_time', 'edited_by', 'edited_time', 'files', 'formula', 'rollup'].sort(),
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('offers a checkbox equality and not a substring', () => {
|
|
36
|
+
expect(operatorsFor('checkbox')).toContain('equals')
|
|
37
|
+
expect(operatorsFor('checkbox')).not.toContain('contains')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('offers a multi-select membership and not a prefix', () => {
|
|
41
|
+
expect(operatorsFor('multi_select')).toContain('is_any_of')
|
|
42
|
+
expect(operatorsFor('multi_select')).not.toContain('starts_with')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('never offers an operator with no value editor for a type that needs one', () => {
|
|
46
|
+
for (const type of PropertyType.options)
|
|
47
|
+
expect(operatorsFor(type).length, `${type} can be filtered by nothing at all`).toBeGreaterThan(0)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('keeps files out of the picker, because nothing can fill one', () => {
|
|
51
|
+
expect(CREATABLE_TYPES).not.toContain('files')
|
|
52
|
+
expect(CREATABLE_TYPES).toContain('text')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('only groups a board by a column whose values are a closed set', () => {
|
|
56
|
+
const groupable = PropertyType.options.filter((t) => descriptorFor(t).canGroup).sort()
|
|
57
|
+
expect(groupable).toEqual(['checkbox', 'select', 'status'])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('leaves timeline out of the kinds it offers, because none of it is built', () => {
|
|
61
|
+
expect(VIEW_KINDS.map((v) => v.kind)).not.toContain('timeline')
|
|
62
|
+
expect(VIEW_KINDS.map((v) => v.kind)).toEqual(['table', 'board', 'gallery', 'list', 'calendar'])
|
|
63
|
+
})
|
|
64
|
+
})
|