@kernhq/module-quire 0.7.4 → 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/document.d.ts +13 -0
- package/dist/server/document.d.ts.map +1 -0
- package/dist/server/document.js +58 -0
- package/dist/server/document.js.map +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +26 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/render.d.ts +53 -0
- package/dist/server/render.d.ts.map +1 -0
- package/dist/server/render.js +358 -0
- package/dist/server/render.js.map +1 -0
- 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/dist/server/services/versions.d.ts.map +1 -1
- package/dist/server/services/versions.js +13 -1
- package/dist/server/services/versions.js.map +1 -1
- package/migrations/0006_rank_collation.sql +14 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +12 -9
- package/src/client/components/PageEditor.svelte +7 -0
- 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,785 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
coreApi,
|
|
5
|
+
Dialog,
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
EmptyState,
|
|
8
|
+
Icon,
|
|
9
|
+
IconButton,
|
|
10
|
+
keys,
|
|
11
|
+
type MenuItem,
|
|
12
|
+
navigation,
|
|
13
|
+
Skeleton,
|
|
14
|
+
Tabs,
|
|
15
|
+
Toolbar,
|
|
16
|
+
ToolbarButton,
|
|
17
|
+
toast,
|
|
18
|
+
} from '@kernhq/ui'
|
|
19
|
+
import { createInfiniteQuery, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
20
|
+
import type {
|
|
21
|
+
Property,
|
|
22
|
+
PropertyConfig,
|
|
23
|
+
PropertyType,
|
|
24
|
+
Row,
|
|
25
|
+
View,
|
|
26
|
+
ViewConfig,
|
|
27
|
+
ViewKind,
|
|
28
|
+
} from '../../contract/index.js'
|
|
29
|
+
import { getQuireApi } from '../api-instance.js'
|
|
30
|
+
import { type CoreApi, toPerson } from '../core-api.js'
|
|
31
|
+
import { t } from '../i18n.js'
|
|
32
|
+
import { canQuire } from '../permissions.js'
|
|
33
|
+
import { quireKeys } from '../query.js'
|
|
34
|
+
import BoardView from './BoardView.svelte'
|
|
35
|
+
import CalendarView from './CalendarView.svelte'
|
|
36
|
+
import FilterMenu from './FilterMenu.svelte'
|
|
37
|
+
import GalleryView from './GalleryView.svelte'
|
|
38
|
+
import ListView from './ListView.svelte'
|
|
39
|
+
import PropertyDialog from './PropertyDialog.svelte'
|
|
40
|
+
import { descriptorFor, viewIcon } from './property-types.js'
|
|
41
|
+
import RowPanel from './RowPanel.svelte'
|
|
42
|
+
import SortMenu from './SortMenu.svelte'
|
|
43
|
+
import TableView from './TableView.svelte'
|
|
44
|
+
import ViewDialog from './ViewDialog.svelte'
|
|
45
|
+
import { EMPTY_GROUP, groupValue, mergeConfig, orderedProperties } from './view-config.js'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A database page: the view tabs, the 52px toolbar, and whichever view is chosen.
|
|
49
|
+
*
|
|
50
|
+
* Everything that writes lives here rather than in the views, so there is one place that knows a
|
|
51
|
+
* `ViewConfig` is replaced wholesale and one place that reports a failure. The views take data and
|
|
52
|
+
* callbacks and hold no queries of their own.
|
|
53
|
+
*
|
|
54
|
+
* The table pages lazily; the other four ask for one large page, because a board cannot show a
|
|
55
|
+
* "load more" button at the bottom of a lane without lying about what the lane contains. That cap
|
|
56
|
+
* is stated on screen rather than left as a silently short list.
|
|
57
|
+
*/
|
|
58
|
+
interface Props {
|
|
59
|
+
workspaceId: string
|
|
60
|
+
spaceKey: string
|
|
61
|
+
pageId: string
|
|
62
|
+
spaceId: string
|
|
63
|
+
}
|
|
64
|
+
const { workspaceId, spaceKey, pageId, spaceId }: Props = $props()
|
|
65
|
+
|
|
66
|
+
const api = getQuireApi()
|
|
67
|
+
const core = coreApi<CoreApi>()
|
|
68
|
+
const client = useQueryClient()
|
|
69
|
+
|
|
70
|
+
const PAGE_SIZE = 50
|
|
71
|
+
/** What a non-paging view will show before it says it has stopped. */
|
|
72
|
+
const CAP = 500
|
|
73
|
+
|
|
74
|
+
const canEdit = $derived(canQuire('pageEdit'))
|
|
75
|
+
const canCreate = $derived(canQuire('pageCreate'))
|
|
76
|
+
|
|
77
|
+
// ---- data ---------------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
const forPage = createQuery(() => ({
|
|
80
|
+
queryKey: quireKeys.databaseForPage(workspaceId, pageId),
|
|
81
|
+
enabled: Boolean(workspaceId && pageId),
|
|
82
|
+
queryFn: () => api.databases.forPage({ workspaceId, pageId }),
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
const databaseId = $derived(forPage.data?.id ?? '')
|
|
86
|
+
|
|
87
|
+
const databaseQuery = createQuery(() => ({
|
|
88
|
+
queryKey: quireKeys.database(workspaceId, databaseId),
|
|
89
|
+
enabled: Boolean(workspaceId && databaseId),
|
|
90
|
+
queryFn: () => api.databases.get({ workspaceId, databaseId }),
|
|
91
|
+
}))
|
|
92
|
+
|
|
93
|
+
const database = $derived(databaseQuery.data ?? forPage.data ?? null)
|
|
94
|
+
const views = $derived(database?.views ?? [])
|
|
95
|
+
|
|
96
|
+
let chosenViewId = $state<string | null>(null)
|
|
97
|
+
const view = $derived<View | null>(
|
|
98
|
+
views.find((v) => v.id === chosenViewId) ?? views.find((v) => v.isDefault) ?? views[0] ?? null,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
/** `nextCursor` is an offset the server minted; the client never builds one. */
|
|
102
|
+
type RowPage = { items: Row[]; nextCursor: string | null }
|
|
103
|
+
|
|
104
|
+
const rowsQuery = createInfiniteQuery(() => ({
|
|
105
|
+
queryKey: quireKeys.rows(workspaceId, databaseId, view?.id ?? null),
|
|
106
|
+
enabled: Boolean(workspaceId && databaseId && view),
|
|
107
|
+
initialPageParam: null as string | null,
|
|
108
|
+
queryFn: ({ pageParam }: { pageParam: string | null }): Promise<RowPage> =>
|
|
109
|
+
api.databases.rows({
|
|
110
|
+
workspaceId,
|
|
111
|
+
databaseId,
|
|
112
|
+
viewId: view?.id ?? null,
|
|
113
|
+
limit: PAGE_SIZE,
|
|
114
|
+
cursor: pageParam ?? undefined,
|
|
115
|
+
}),
|
|
116
|
+
getNextPageParam: (last: RowPage) => last.nextCursor,
|
|
117
|
+
}))
|
|
118
|
+
|
|
119
|
+
const rows = $derived<Row[]>(((rowsQuery.data?.pages ?? []) as RowPage[]).flatMap((p) => p.items))
|
|
120
|
+
|
|
121
|
+
const membersQuery = createQuery(() => ({
|
|
122
|
+
queryKey: keys.members(workspaceId),
|
|
123
|
+
enabled: Boolean(workspaceId),
|
|
124
|
+
queryFn: () => core.workspaces.members.list({ workspaceId, limit: 200 }),
|
|
125
|
+
}))
|
|
126
|
+
const people = $derived((membersQuery.data?.items ?? []).map(toPerson))
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A board, gallery, list or calendar wants the whole set at once, so it keeps asking until it has
|
|
130
|
+
* it — up to the cap, which is then said out loud.
|
|
131
|
+
*/
|
|
132
|
+
const paging = $derived(view?.kind === 'table')
|
|
133
|
+
$effect(() => {
|
|
134
|
+
if (paging || !rowsQuery.hasNextPage || rowsQuery.isFetchingNextPage) return
|
|
135
|
+
if (rows.length >= CAP) return
|
|
136
|
+
void rowsQuery.fetchNextPage()
|
|
137
|
+
})
|
|
138
|
+
const capped = $derived(!paging && rows.length >= CAP && Boolean(rowsQuery.hasNextPage))
|
|
139
|
+
|
|
140
|
+
// ---- state --------------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
let busy = $state(false)
|
|
143
|
+
let filterOpen = $state(false)
|
|
144
|
+
let sortOpen = $state(false)
|
|
145
|
+
let propertyDialog = $state<{ property: Property | null } | null>(null)
|
|
146
|
+
let viewDialog = $state<{ view: View | null } | null>(null)
|
|
147
|
+
let confirming = $state<
|
|
148
|
+
{ kind: 'property'; property: Property } | { kind: 'view'; view: View } | { kind: 'row'; row: Row } | null
|
|
149
|
+
>(null)
|
|
150
|
+
let inspecting = $state<string | null>(null)
|
|
151
|
+
|
|
152
|
+
const inspected = $derived(inspecting ? (rows.find((r) => r.id === inspecting) ?? null) : null)
|
|
153
|
+
$effect(() => {
|
|
154
|
+
// The row was deleted, or a filter now excludes it; the panel must not linger over nothing.
|
|
155
|
+
if (inspecting && rows.length > 0 && !rows.some((r) => r.id === inspecting)) inspecting = null
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const properties = $derived(database ? orderedProperties(database) : [])
|
|
159
|
+
const relations = $derived(properties.filter((p) => p.type === 'relation'))
|
|
160
|
+
const groupProperty = $derived(
|
|
161
|
+
view?.config.groupBy ? (properties.find((p) => p.key === view.config.groupBy) ?? null) : null,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
// ---- writing ------------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const refreshSchema = async () => {
|
|
167
|
+
await client.invalidateQueries({ queryKey: quireKeys.databaseForPage(workspaceId, pageId) })
|
|
168
|
+
await client.invalidateQueries({ queryKey: quireKeys.database(workspaceId, databaseId) })
|
|
169
|
+
}
|
|
170
|
+
const refreshRows = () => client.invalidateQueries({ queryKey: ['quire', 'row', workspaceId, databaseId] })
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* One place that reports a failure, and one flag that stops a second click.
|
|
174
|
+
*
|
|
175
|
+
* `disabled={busy}` alone does not: the attribute reaches the button on the next render and two
|
|
176
|
+
* quick clicks are one render apart, so the guard is read here in the same tick as the click.
|
|
177
|
+
*/
|
|
178
|
+
async function act(work: () => Promise<unknown>, after: () => Promise<unknown>) {
|
|
179
|
+
if (busy) return
|
|
180
|
+
busy = true
|
|
181
|
+
try {
|
|
182
|
+
await work()
|
|
183
|
+
await after()
|
|
184
|
+
} catch (err) {
|
|
185
|
+
toast.error(err instanceof Error && err.message ? err.message : t('common.error'))
|
|
186
|
+
} finally {
|
|
187
|
+
busy = false
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const patchView = (patch: Partial<ViewConfig>) => {
|
|
192
|
+
if (!view) return
|
|
193
|
+
// `updateView` replaces `config` wholesale, so a partial write deletes the rest of the view.
|
|
194
|
+
const config = mergeConfig(view.config, patch)
|
|
195
|
+
void act(() => api.databases.updateView({ workspaceId, viewId: view.id, config }), refreshSchema)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const writeCell = (row: Row, property: Property, value: unknown) =>
|
|
199
|
+
act(
|
|
200
|
+
() => api.databases.updateRow({ workspaceId, rowId: row.id, props: { [property.key]: value } }),
|
|
201
|
+
refreshRows,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
const writeTitle = (row: Row, title: string) => {
|
|
205
|
+
if (title === row.title) return Promise.resolve()
|
|
206
|
+
return act(
|
|
207
|
+
() => api.databases.updateRow({ workspaceId, rowId: row.id, title }),
|
|
208
|
+
async () => {
|
|
209
|
+
await refreshRows()
|
|
210
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const addRow = (seed: Record<string, unknown> = {}) =>
|
|
216
|
+
act(
|
|
217
|
+
() => api.databases.addRow({ workspaceId, databaseId, title: '', props: seed }),
|
|
218
|
+
async () => {
|
|
219
|
+
await refreshRows()
|
|
220
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
const duplicateRow = (row: Row) =>
|
|
225
|
+
act(
|
|
226
|
+
() =>
|
|
227
|
+
api.databases.addRow({
|
|
228
|
+
workspaceId,
|
|
229
|
+
databaseId,
|
|
230
|
+
title: row.title,
|
|
231
|
+
props: $state.snapshot(row.props),
|
|
232
|
+
}),
|
|
233
|
+
refreshRows,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
const openPage = (row: Row) =>
|
|
237
|
+
navigation.go(
|
|
238
|
+
`/${navigation.workspaceSlug}/quire/${encodeURIComponent(spaceKey)}/${encodeURIComponent(row.id)}`,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
const moveOnBoard = (row: Row, laneId: string) => {
|
|
242
|
+
if (!groupProperty) return Promise.resolve()
|
|
243
|
+
const value = groupValue(laneId)
|
|
244
|
+
return act(
|
|
245
|
+
() =>
|
|
246
|
+
api.databases.updateRow({
|
|
247
|
+
workspaceId,
|
|
248
|
+
rowId: row.id,
|
|
249
|
+
props: {
|
|
250
|
+
[groupProperty.key]: groupProperty.type === 'checkbox' ? value === 'true' : value,
|
|
251
|
+
},
|
|
252
|
+
}),
|
|
253
|
+
refreshRows,
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const setDate = (row: Row, iso: string | null) => {
|
|
258
|
+
const key = view?.config.dateProperty
|
|
259
|
+
if (!key) return Promise.resolve()
|
|
260
|
+
return act(
|
|
261
|
+
() => api.databases.updateRow({ workspaceId, rowId: row.id, props: { [key]: iso } }),
|
|
262
|
+
refreshRows,
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const submitProperty = (input: { name: string; type: PropertyType; config: PropertyConfig }) => {
|
|
267
|
+
const editing = propertyDialog?.property ?? null
|
|
268
|
+
return act(
|
|
269
|
+
() =>
|
|
270
|
+
editing
|
|
271
|
+
? api.databases.updateProperty({ workspaceId, propertyId: editing.id, ...input })
|
|
272
|
+
: api.databases.addProperty({ workspaceId, databaseId, ...input }),
|
|
273
|
+
async () => {
|
|
274
|
+
propertyDialog = null
|
|
275
|
+
await refreshSchema()
|
|
276
|
+
await refreshRows()
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const moveProperty = (property: Property, direction: -1 | 1) => {
|
|
282
|
+
const order = properties.filter((p) => !p.hidden)
|
|
283
|
+
const at = order.findIndex((p) => p.id === property.id)
|
|
284
|
+
const to = at + direction
|
|
285
|
+
if (at < 0 || to < 0 || to >= order.length) return Promise.resolve()
|
|
286
|
+
// Landing "before" the previous column means following the one before *that*, or nothing.
|
|
287
|
+
const afterId = direction === 1 ? (order[to]?.id ?? null) : (order[to - 1]?.id ?? null)
|
|
288
|
+
return act(
|
|
289
|
+
() => api.databases.moveProperty({ workspaceId, propertyId: property.id, afterId }),
|
|
290
|
+
refreshSchema,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const hideProperty = (property: Property) =>
|
|
295
|
+
act(
|
|
296
|
+
() => api.databases.updateProperty({ workspaceId, propertyId: property.id, hidden: !property.hidden }),
|
|
297
|
+
refreshSchema,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
const sortBy = (property: Property, direction: 'asc' | 'desc' | null) => {
|
|
301
|
+
if (!view) return
|
|
302
|
+
const rest = view.config.sorts.filter((s) => s.propertyKey !== property.key)
|
|
303
|
+
patchView({ sorts: direction ? [{ propertyKey: property.key, direction }, ...rest] : rest })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const sortDirectionOf = (key: string) =>
|
|
307
|
+
view?.config.sorts.find((s) => s.propertyKey === key)?.direction ?? null
|
|
308
|
+
|
|
309
|
+
function filterBy(property: Property) {
|
|
310
|
+
if (!view) return
|
|
311
|
+
const already = view.config.filters.some((f) => f.propertyKey === property.key)
|
|
312
|
+
if (!already) {
|
|
313
|
+
const operator = descriptorFor(property.type).operators[0] ?? 'equals'
|
|
314
|
+
patchView({ filters: [...view.config.filters, { propertyKey: property.key, operator, value: null }] })
|
|
315
|
+
}
|
|
316
|
+
filterOpen = true
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const submitView = (input: { name: string; kind: ViewKind; config: ViewConfig }) => {
|
|
320
|
+
const editing = viewDialog?.view ?? null
|
|
321
|
+
return act(
|
|
322
|
+
async () => {
|
|
323
|
+
if (editing) return api.databases.updateView({ workspaceId, viewId: editing.id, ...input })
|
|
324
|
+
const created = await api.databases.addView({ workspaceId, databaseId, ...input })
|
|
325
|
+
chosenViewId = created.id
|
|
326
|
+
return created
|
|
327
|
+
},
|
|
328
|
+
async () => {
|
|
329
|
+
viewDialog = null
|
|
330
|
+
await refreshSchema()
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function confirmed() {
|
|
336
|
+
const target = confirming
|
|
337
|
+
if (!target) return
|
|
338
|
+
if (target.kind === 'property')
|
|
339
|
+
void act(
|
|
340
|
+
() => api.databases.removeProperty({ workspaceId, propertyId: target.property.id }),
|
|
341
|
+
async () => {
|
|
342
|
+
confirming = null
|
|
343
|
+
await refreshSchema()
|
|
344
|
+
await refreshRows()
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
else if (target.kind === 'view')
|
|
348
|
+
void act(
|
|
349
|
+
() => api.databases.removeView({ workspaceId, viewId: target.view.id }),
|
|
350
|
+
async () => {
|
|
351
|
+
confirming = null
|
|
352
|
+
if (chosenViewId === target.view.id) chosenViewId = null
|
|
353
|
+
await refreshSchema()
|
|
354
|
+
},
|
|
355
|
+
)
|
|
356
|
+
else
|
|
357
|
+
void act(
|
|
358
|
+
() => api.pages.trashPage({ workspaceId, pageId: target.row.id }),
|
|
359
|
+
async () => {
|
|
360
|
+
confirming = null
|
|
361
|
+
if (inspecting === target.row.id) inspecting = null
|
|
362
|
+
await refreshRows()
|
|
363
|
+
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const confirmTitle = $derived(
|
|
369
|
+
confirming?.kind === 'property'
|
|
370
|
+
? t('db_delete_property_confirm', { name: confirming.property.name })
|
|
371
|
+
: confirming?.kind === 'view'
|
|
372
|
+
? t('db_delete_view_confirm', { name: confirming.view.name })
|
|
373
|
+
: confirming?.kind === 'row'
|
|
374
|
+
? t('db_delete_row_confirm', { title: confirming.row.title.trim() || t('untitled') })
|
|
375
|
+
: '',
|
|
376
|
+
)
|
|
377
|
+
const confirmBody = $derived(
|
|
378
|
+
confirming?.kind === 'property'
|
|
379
|
+
? t('db_delete_property_desc')
|
|
380
|
+
: confirming?.kind === 'view'
|
|
381
|
+
? t('db_delete_view_desc')
|
|
382
|
+
: confirming?.kind === 'row'
|
|
383
|
+
? t('db_delete_row_desc')
|
|
384
|
+
: '',
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
// ---- toolbar menus -------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
const groupItems = $derived<MenuItem[]>([
|
|
390
|
+
{
|
|
391
|
+
type: 'radio',
|
|
392
|
+
value: view?.config.groupBy ?? '',
|
|
393
|
+
options: [
|
|
394
|
+
{ value: '', label: t('db_group_none') },
|
|
395
|
+
...properties
|
|
396
|
+
.filter((p) => descriptorFor(p.type).canGroup)
|
|
397
|
+
.map((p) => ({ value: p.key, label: p.name, icon: descriptorFor(p.type).icon })),
|
|
398
|
+
],
|
|
399
|
+
onValueChange: (next: string) => patchView({ groupBy: next || null }),
|
|
400
|
+
},
|
|
401
|
+
])
|
|
402
|
+
|
|
403
|
+
const columnItems = $derived<MenuItem[]>(
|
|
404
|
+
properties.length === 0
|
|
405
|
+
? [{ type: 'label', label: t('db_no_columns') }]
|
|
406
|
+
: properties.map((property) => ({
|
|
407
|
+
type: 'checkbox' as const,
|
|
408
|
+
id: property.id,
|
|
409
|
+
label: property.name,
|
|
410
|
+
icon: descriptorFor(property.type).icon,
|
|
411
|
+
checked: !property.hidden,
|
|
412
|
+
disabled: !canEdit,
|
|
413
|
+
onCheckedChange: () => void hideProperty(property),
|
|
414
|
+
})),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
const viewItems = $derived<MenuItem[]>([
|
|
418
|
+
{
|
|
419
|
+
id: 'edit',
|
|
420
|
+
label: t('db_edit_view'),
|
|
421
|
+
icon: 'sliders-vertical',
|
|
422
|
+
disabled: !canEdit || !view,
|
|
423
|
+
onSelect: () => {
|
|
424
|
+
if (view) viewDialog = { view }
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
id: 'add',
|
|
429
|
+
label: t('db_add_view'),
|
|
430
|
+
icon: 'plus',
|
|
431
|
+
disabled: !canEdit,
|
|
432
|
+
onSelect: () => (viewDialog = { view: null }),
|
|
433
|
+
},
|
|
434
|
+
{ type: 'separator' },
|
|
435
|
+
{
|
|
436
|
+
id: 'delete',
|
|
437
|
+
label: t('db_delete_view'),
|
|
438
|
+
icon: 'trash-2',
|
|
439
|
+
danger: true,
|
|
440
|
+
disabled: !canEdit || !view || views.length <= 1,
|
|
441
|
+
hint: views.length <= 1 ? t('db_view_last') : undefined,
|
|
442
|
+
onSelect: () => {
|
|
443
|
+
if (view) confirming = { kind: 'view', view }
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
])
|
|
447
|
+
|
|
448
|
+
const tabs = $derived(views.map((v) => ({ value: v.id, label: v.name, icon: viewIcon(v.kind) })))
|
|
449
|
+
|
|
450
|
+
const loading = $derived(forPage.isLoading || (Boolean(databaseId) && databaseQuery.isLoading))
|
|
451
|
+
const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.isError)
|
|
452
|
+
</script>
|
|
453
|
+
|
|
454
|
+
{#if loading}
|
|
455
|
+
<div class="pad">
|
|
456
|
+
<Skeleton height="34px" />
|
|
457
|
+
<div class="gap"></div>
|
|
458
|
+
<Skeleton height="18px" lines={8} />
|
|
459
|
+
</div>
|
|
460
|
+
{:else if failed}
|
|
461
|
+
<div class="pad">
|
|
462
|
+
<EmptyState icon="triangle-alert" title={t('db_error')} description={t('db_error_desc')}>
|
|
463
|
+
{#snippet actions()}
|
|
464
|
+
<Button
|
|
465
|
+
variant="secondary"
|
|
466
|
+
onclick={() => {
|
|
467
|
+
void forPage.refetch()
|
|
468
|
+
void databaseQuery.refetch()
|
|
469
|
+
void rowsQuery.refetch()
|
|
470
|
+
}}
|
|
471
|
+
>
|
|
472
|
+
{t('common.retry')}
|
|
473
|
+
</Button>
|
|
474
|
+
{/snippet}
|
|
475
|
+
</EmptyState>
|
|
476
|
+
</div>
|
|
477
|
+
{:else if !database}
|
|
478
|
+
<div class="pad">
|
|
479
|
+
<EmptyState icon="database" title={t('db_missing')} description={t('db_missing_desc')} />
|
|
480
|
+
</div>
|
|
481
|
+
{:else}
|
|
482
|
+
<div class="head">
|
|
483
|
+
{#if tabs.length > 0}
|
|
484
|
+
<Tabs
|
|
485
|
+
items={tabs}
|
|
486
|
+
value={view?.id ?? tabs[0]?.value ?? ''}
|
|
487
|
+
variant="underline"
|
|
488
|
+
label={t('db_views')}
|
|
489
|
+
onValueChange={(next) => (chosenViewId = next)}
|
|
490
|
+
/>
|
|
491
|
+
{/if}
|
|
492
|
+
<span class="sp"></span>
|
|
493
|
+
<DropdownMenu items={viewItems}>
|
|
494
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
495
|
+
<IconButton {...props} icon="ellipsis" label={t('db_view_actions')} size={28} variant="ghost" />
|
|
496
|
+
{/snippet}
|
|
497
|
+
</DropdownMenu>
|
|
498
|
+
</div>
|
|
499
|
+
|
|
500
|
+
<Toolbar>
|
|
501
|
+
<FilterMenu
|
|
502
|
+
{database}
|
|
503
|
+
config={view?.config ?? { filters: [], filterMode: 'and', sorts: [], groupBy: null, dateProperty: null, visibleProperties: null, columnWidths: {}, cardSize: 'medium', coverProperty: null }}
|
|
504
|
+
{workspaceId}
|
|
505
|
+
{people}
|
|
506
|
+
{canEdit}
|
|
507
|
+
open={filterOpen}
|
|
508
|
+
onOpenChange={(o) => (filterOpen = o)}
|
|
509
|
+
onchange={patchView}
|
|
510
|
+
/>
|
|
511
|
+
<SortMenu
|
|
512
|
+
{database}
|
|
513
|
+
config={view?.config ?? { filters: [], filterMode: 'and', sorts: [], groupBy: null, dateProperty: null, visibleProperties: null, columnWidths: {}, cardSize: 'medium', coverProperty: null }}
|
|
514
|
+
{canEdit}
|
|
515
|
+
open={sortOpen}
|
|
516
|
+
onOpenChange={(o) => (sortOpen = o)}
|
|
517
|
+
onchange={patchView}
|
|
518
|
+
/>
|
|
519
|
+
{#if view?.kind === 'board'}
|
|
520
|
+
<DropdownMenu items={groupItems} align="start">
|
|
521
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
522
|
+
<ToolbarButton {...props} prefix={t('db_by')} active={Boolean(view?.config.groupBy)}>
|
|
523
|
+
{groupProperty?.name ?? t('db_group_none')}
|
|
524
|
+
</ToolbarButton>
|
|
525
|
+
{/snippet}
|
|
526
|
+
</DropdownMenu>
|
|
527
|
+
{/if}
|
|
528
|
+
<DropdownMenu items={columnItems} align="start">
|
|
529
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
530
|
+
<ToolbarButton {...props} icon="columns-3">{t('db_properties')}</ToolbarButton>
|
|
531
|
+
{/snippet}
|
|
532
|
+
</DropdownMenu>
|
|
533
|
+
|
|
534
|
+
{#snippet end()}
|
|
535
|
+
<span class="count">{t('db_rows', { n: rows.length })}</span>
|
|
536
|
+
{#if canCreate}
|
|
537
|
+
<Button size="sm" disabled={busy} onclick={() => void addRow()}>{t('db_new_row')}</Button>
|
|
538
|
+
{/if}
|
|
539
|
+
{/snippet}
|
|
540
|
+
</Toolbar>
|
|
541
|
+
|
|
542
|
+
<div class="body">
|
|
543
|
+
{#if rowsQuery.isLoading}
|
|
544
|
+
<div class="pad"><Skeleton height="40px" lines={6} /></div>
|
|
545
|
+
{:else if rows.length === 0}
|
|
546
|
+
<div class="pad">
|
|
547
|
+
<EmptyState icon="database" title={t('db_empty')} description={t('db_empty_desc')}>
|
|
548
|
+
{#snippet actions()}
|
|
549
|
+
{#if canCreate}
|
|
550
|
+
<Button disabled={busy} onclick={() => void addRow()}>{t('db_new_row')}</Button>
|
|
551
|
+
{/if}
|
|
552
|
+
{/snippet}
|
|
553
|
+
</EmptyState>
|
|
554
|
+
</div>
|
|
555
|
+
{:else if !view || view.kind === 'table' || view.kind === 'timeline'}
|
|
556
|
+
{#if view?.kind === 'timeline'}
|
|
557
|
+
<p class="notice" role="status">
|
|
558
|
+
<Icon name="circle-alert" size={14} strokeWidth={1.7} />
|
|
559
|
+
{t('db_timeline_unbuilt')}
|
|
560
|
+
</p>
|
|
561
|
+
{/if}
|
|
562
|
+
<TableView
|
|
563
|
+
{database}
|
|
564
|
+
{view}
|
|
565
|
+
{rows}
|
|
566
|
+
{people}
|
|
567
|
+
{workspaceId}
|
|
568
|
+
{canEdit}
|
|
569
|
+
{canCreate}
|
|
570
|
+
{sortDirectionOf}
|
|
571
|
+
onCellChange={(row, property, value) => void writeCell(row, property, value)}
|
|
572
|
+
onTitleChange={(row, title) => void writeTitle(row, title)}
|
|
573
|
+
onOpenRow={(row) => (inspecting = row.id)}
|
|
574
|
+
onOpenPage={(row) => void openPage(row)}
|
|
575
|
+
onDuplicateRow={(row) => void duplicateRow(row)}
|
|
576
|
+
onDeleteRow={(row) => (confirming = { kind: 'row', row })}
|
|
577
|
+
onAddRow={() => void addRow()}
|
|
578
|
+
onAddProperty={() => (propertyDialog = { property: null })}
|
|
579
|
+
onEditProperty={(property) => (propertyDialog = { property })}
|
|
580
|
+
onMoveProperty={(property, direction) => void moveProperty(property, direction)}
|
|
581
|
+
onHideProperty={(property) => void hideProperty(property)}
|
|
582
|
+
onDeleteProperty={(property) => (confirming = { kind: 'property', property })}
|
|
583
|
+
onSortBy={sortBy}
|
|
584
|
+
onFilterBy={filterBy}
|
|
585
|
+
onConfigChange={patchView}
|
|
586
|
+
/>
|
|
587
|
+
{#if rowsQuery.hasNextPage}
|
|
588
|
+
<div class="more">
|
|
589
|
+
<Button
|
|
590
|
+
variant="secondary"
|
|
591
|
+
disabled={rowsQuery.isFetchingNextPage}
|
|
592
|
+
onclick={() => void rowsQuery.fetchNextPage()}
|
|
593
|
+
>
|
|
594
|
+
{rowsQuery.isFetchingNextPage ? t('common.loading') : t('db_load_more')}
|
|
595
|
+
</Button>
|
|
596
|
+
</div>
|
|
597
|
+
{/if}
|
|
598
|
+
{:else if view.kind === 'board'}
|
|
599
|
+
<BoardView
|
|
600
|
+
{database}
|
|
601
|
+
{view}
|
|
602
|
+
{rows}
|
|
603
|
+
{people}
|
|
604
|
+
{workspaceId}
|
|
605
|
+
{canEdit}
|
|
606
|
+
{canCreate}
|
|
607
|
+
onMove={(row, laneId) => void moveOnBoard(row, laneId)}
|
|
608
|
+
onOpenRow={(row) => (inspecting = row.id)}
|
|
609
|
+
onAddRow={(laneId) =>
|
|
610
|
+
void addRow(
|
|
611
|
+
groupProperty && laneId !== EMPTY_GROUP
|
|
612
|
+
? { [groupProperty.key]: groupProperty.type === 'checkbox' ? laneId === 'true' : laneId }
|
|
613
|
+
: {},
|
|
614
|
+
)}
|
|
615
|
+
onConfigure={() => {
|
|
616
|
+
if (view) viewDialog = { view }
|
|
617
|
+
}}
|
|
618
|
+
/>
|
|
619
|
+
{:else if view.kind === 'gallery'}
|
|
620
|
+
<GalleryView
|
|
621
|
+
{database}
|
|
622
|
+
{view}
|
|
623
|
+
{rows}
|
|
624
|
+
{people}
|
|
625
|
+
{workspaceId}
|
|
626
|
+
{canEdit}
|
|
627
|
+
onOpenRow={(row) => (inspecting = row.id)}
|
|
628
|
+
onOpenPage={(row) => void openPage(row)}
|
|
629
|
+
onDeleteRow={(row) => (confirming = { kind: 'row', row })}
|
|
630
|
+
/>
|
|
631
|
+
{:else if view.kind === 'list'}
|
|
632
|
+
<ListView
|
|
633
|
+
{database}
|
|
634
|
+
{view}
|
|
635
|
+
{rows}
|
|
636
|
+
{people}
|
|
637
|
+
{workspaceId}
|
|
638
|
+
{canEdit}
|
|
639
|
+
onOpenRow={(row) => (inspecting = row.id)}
|
|
640
|
+
onOpenPage={(row) => void openPage(row)}
|
|
641
|
+
onDeleteRow={(row) => (confirming = { kind: 'row', row })}
|
|
642
|
+
/>
|
|
643
|
+
{:else}
|
|
644
|
+
<CalendarView
|
|
645
|
+
{database}
|
|
646
|
+
{view}
|
|
647
|
+
{rows}
|
|
648
|
+
{canEdit}
|
|
649
|
+
onOpenRow={(row) => (inspecting = row.id)}
|
|
650
|
+
onSetDate={(row, iso) => void setDate(row, iso)}
|
|
651
|
+
onConfigure={() => {
|
|
652
|
+
if (view) viewDialog = { view }
|
|
653
|
+
}}
|
|
654
|
+
/>
|
|
655
|
+
{/if}
|
|
656
|
+
|
|
657
|
+
{#if capped}
|
|
658
|
+
<p class="notice" role="status">
|
|
659
|
+
<Icon name="circle-alert" size={14} strokeWidth={1.7} />
|
|
660
|
+
{t('db_capped', { n: CAP })}
|
|
661
|
+
</p>
|
|
662
|
+
{/if}
|
|
663
|
+
</div>
|
|
664
|
+
{/if}
|
|
665
|
+
|
|
666
|
+
{#if database && inspected}
|
|
667
|
+
<RowPanel
|
|
668
|
+
{database}
|
|
669
|
+
row={inspected}
|
|
670
|
+
{people}
|
|
671
|
+
{workspaceId}
|
|
672
|
+
{canEdit}
|
|
673
|
+
onClose={() => (inspecting = null)}
|
|
674
|
+
onChange={(property, value) => void writeCell(inspected, property, value)}
|
|
675
|
+
onTitleChange={(title) => void writeTitle(inspected, title)}
|
|
676
|
+
onOpenPage={() => void openPage(inspected)}
|
|
677
|
+
/>
|
|
678
|
+
{/if}
|
|
679
|
+
|
|
680
|
+
{#if database}
|
|
681
|
+
<PropertyDialog
|
|
682
|
+
open={propertyDialog !== null}
|
|
683
|
+
{workspaceId}
|
|
684
|
+
{spaceId}
|
|
685
|
+
{databaseId}
|
|
686
|
+
property={propertyDialog?.property ?? null}
|
|
687
|
+
{relations}
|
|
688
|
+
{busy}
|
|
689
|
+
onClose={() => (propertyDialog = null)}
|
|
690
|
+
onSubmit={(input) => void submitProperty(input)}
|
|
691
|
+
/>
|
|
692
|
+
<ViewDialog
|
|
693
|
+
open={viewDialog !== null}
|
|
694
|
+
{database}
|
|
695
|
+
view={viewDialog?.view ?? null}
|
|
696
|
+
{busy}
|
|
697
|
+
onClose={() => (viewDialog = null)}
|
|
698
|
+
onSubmit={(input) => void submitView(input)}
|
|
699
|
+
/>
|
|
700
|
+
{/if}
|
|
701
|
+
|
|
702
|
+
<Dialog
|
|
703
|
+
open={confirming !== null}
|
|
704
|
+
title={confirmTitle}
|
|
705
|
+
size="sm"
|
|
706
|
+
onOpenChange={(o) => !o && (confirming = null)}
|
|
707
|
+
>
|
|
708
|
+
<p class="confirm">{confirmBody}</p>
|
|
709
|
+
{#snippet footer()}
|
|
710
|
+
<Button variant="secondary" onclick={() => (confirming = null)}>{t('common.cancel')}</Button>
|
|
711
|
+
<Button variant="danger" disabled={busy} onclick={confirmed}>{t('common.delete')}</Button>
|
|
712
|
+
{/snippet}
|
|
713
|
+
</Dialog>
|
|
714
|
+
|
|
715
|
+
<style>
|
|
716
|
+
.head {
|
|
717
|
+
display: flex;
|
|
718
|
+
align-items: stretch;
|
|
719
|
+
gap: 10px;
|
|
720
|
+
height: 44px;
|
|
721
|
+
padding: 0 28px;
|
|
722
|
+
border-block-end: 1px solid var(--kern-border);
|
|
723
|
+
background: var(--kern-surface);
|
|
724
|
+
flex: none;
|
|
725
|
+
}
|
|
726
|
+
.head .sp {
|
|
727
|
+
flex: 1;
|
|
728
|
+
}
|
|
729
|
+
.head :global(.ktabs) {
|
|
730
|
+
display: flex;
|
|
731
|
+
align-items: stretch;
|
|
732
|
+
}
|
|
733
|
+
/*
|
|
734
|
+
* No scroller of its own: this renders inside `Page`, which already scrolls. Two nested scrollers
|
|
735
|
+
* means a wheel that stops at an invisible boundary halfway down the screen.
|
|
736
|
+
*/
|
|
737
|
+
.body {
|
|
738
|
+
min-height: 0;
|
|
739
|
+
}
|
|
740
|
+
.pad {
|
|
741
|
+
padding: 24px 28px 40px;
|
|
742
|
+
}
|
|
743
|
+
.gap {
|
|
744
|
+
height: 14px;
|
|
745
|
+
}
|
|
746
|
+
.count {
|
|
747
|
+
font-family: var(--kern-font-mono);
|
|
748
|
+
font-size: 11.5px;
|
|
749
|
+
color: var(--kern-ink-450);
|
|
750
|
+
white-space: nowrap;
|
|
751
|
+
}
|
|
752
|
+
.more {
|
|
753
|
+
display: flex;
|
|
754
|
+
justify-content: center;
|
|
755
|
+
padding: 4px 0 32px;
|
|
756
|
+
}
|
|
757
|
+
.notice {
|
|
758
|
+
display: flex;
|
|
759
|
+
align-items: center;
|
|
760
|
+
gap: 8px;
|
|
761
|
+
margin: 0 28px 28px;
|
|
762
|
+
padding: 9px 12px;
|
|
763
|
+
border-radius: var(--kern-r-lg);
|
|
764
|
+
background: var(--kern-warning-tint);
|
|
765
|
+
color: var(--kern-warning);
|
|
766
|
+
font-size: 12.5px;
|
|
767
|
+
}
|
|
768
|
+
.confirm {
|
|
769
|
+
margin: 0;
|
|
770
|
+
font-size: 13.5px;
|
|
771
|
+
line-height: 1.55;
|
|
772
|
+
color: var(--kern-ink-600);
|
|
773
|
+
}
|
|
774
|
+
@media (max-width: 768px) {
|
|
775
|
+
.head {
|
|
776
|
+
padding: 0 16px;
|
|
777
|
+
}
|
|
778
|
+
.pad {
|
|
779
|
+
padding: 16px;
|
|
780
|
+
}
|
|
781
|
+
.notice {
|
|
782
|
+
margin-inline: 16px;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
</style>
|