@kernhq/module-quire 0.9.0 → 0.9.2
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/package.json +1 -1
- package/src/client/components/CommentsPanel.svelte +3 -3
- package/src/client/components/NewSpaceDialog.svelte +2 -2
- package/src/client/components/SidebarSpaces.svelte +1 -1
- package/src/client/components/VersionHistory.svelte +2 -2
- package/src/client/database/BoardView.svelte +5 -4
- package/src/client/database/DatabaseView.svelte +104 -33
- package/src/client/database/GalleryView.svelte +1 -1
- package/src/client/database/PropertyDialog.svelte +3 -3
- package/src/client/database/TableView.svelte +2 -2
- package/src/client/database/ViewDialog.svelte +2 -2
- package/src/client/database/cells/RelationCell.svelte +1 -1
- package/src/client/i18n.ts +40 -0
- package/src/client/mock.ts +22 -8
- package/src/client/pages/PageView.svelte +38 -25
- package/src/client/pages/SpacesPage.svelte +1 -1
package/package.json
CHANGED
|
@@ -114,7 +114,7 @@ async function remove(commentId: string) {
|
|
|
114
114
|
{/if}
|
|
115
115
|
<RichTextEditor bind:value={draft} placeholder={t('comment_placeholder')} minRows={2} />
|
|
116
116
|
<div class="actions">
|
|
117
|
-
<Button size="sm" variant="secondary" onclick={() => onPendingHandled?.()}>{t('
|
|
117
|
+
<Button size="sm" variant="secondary" onclick={() => onPendingHandled?.()}>{t('cancel')}</Button>
|
|
118
118
|
<Button size="sm" disabled={busy || empty(draft)} onclick={() => submit(null, draft)}>
|
|
119
119
|
{t('comment_post')}
|
|
120
120
|
</Button>
|
|
@@ -169,7 +169,7 @@ async function remove(commentId: string) {
|
|
|
169
169
|
icon="trash-2"
|
|
170
170
|
size={22}
|
|
171
171
|
variant="ghost"
|
|
172
|
-
label={t('
|
|
172
|
+
label={t('delete')}
|
|
173
173
|
onclick={() => remove(comment.id)}
|
|
174
174
|
/>
|
|
175
175
|
{/if}
|
|
@@ -183,7 +183,7 @@ async function remove(commentId: string) {
|
|
|
183
183
|
{#if replyTo === thread.id}
|
|
184
184
|
<RichTextEditor bind:value={replyDraft} placeholder={t('comment_reply')} minRows={1} />
|
|
185
185
|
<div class="actions">
|
|
186
|
-
<Button size="sm" variant="secondary" onclick={() => (replyTo = null)}>{t('
|
|
186
|
+
<Button size="sm" variant="secondary" onclick={() => (replyTo = null)}>{t('cancel')}</Button>
|
|
187
187
|
<Button
|
|
188
188
|
size="sm"
|
|
189
189
|
disabled={busy || empty(replyDraft)}
|
|
@@ -122,8 +122,8 @@ async function submit() {
|
|
|
122
122
|
</div>
|
|
123
123
|
|
|
124
124
|
{#snippet footer()}
|
|
125
|
-
<Button variant="secondary" onclick={() => (open = false)}>{t('
|
|
126
|
-
<Button disabled={!valid || saving} onclick={submit}>{t('
|
|
125
|
+
<Button variant="secondary" onclick={() => (open = false)}>{t('cancel')}</Button>
|
|
126
|
+
<Button disabled={!valid || saving} onclick={submit}>{t('create')}</Button>
|
|
127
127
|
{/snippet}
|
|
128
128
|
</Dialog>
|
|
129
129
|
|
|
@@ -67,9 +67,9 @@ const kindLabel = (v: PageVersion) =>
|
|
|
67
67
|
{#each [1, 2, 3, 4] as n (n)}<Skeleton height="56px" />{/each}
|
|
68
68
|
</div>
|
|
69
69
|
{:else if query.isError}
|
|
70
|
-
<EmptyState icon="triangle-alert" title={t('history_error')} description={t('
|
|
70
|
+
<EmptyState icon="triangle-alert" title={t('history_error')} description={t('retry')}>
|
|
71
71
|
{#snippet actions()}
|
|
72
|
-
<Button variant="secondary" onclick={() => void query.refetch()}>{t('
|
|
72
|
+
<Button variant="secondary" onclick={() => void query.refetch()}>{t('retry')}</Button>
|
|
73
73
|
{/snippet}
|
|
74
74
|
</EmptyState>
|
|
75
75
|
{:else if versions.length === 0}
|
|
@@ -143,9 +143,9 @@ const laneRule = (lane: Lane) =>
|
|
|
143
143
|
</EmptyState>
|
|
144
144
|
</div>
|
|
145
145
|
{:else}
|
|
146
|
-
<div class="board">
|
|
146
|
+
<div class="board" data-testid="database-board">
|
|
147
147
|
{#each lanes as lane, index (lane.id)}
|
|
148
|
-
<section class="col">
|
|
148
|
+
<section class="col" data-lane={lane.id}>
|
|
149
149
|
<header class="head">
|
|
150
150
|
{#if lane.option}
|
|
151
151
|
<OptionChip option={lane.option} compact />
|
|
@@ -180,7 +180,7 @@ const laneRule = (lane: Lane) =>
|
|
|
180
180
|
onfinalize={(e) => finalize(index, e)}
|
|
181
181
|
>
|
|
182
182
|
{#each lane.rows as row (row.id)}
|
|
183
|
-
<article class="card" class:shadow={isShadow(row)}>
|
|
183
|
+
<article class="card" class:shadow={isShadow(row)} data-testid="board-card">
|
|
184
184
|
<div class="card-top">
|
|
185
185
|
<h3 class="card-title">{row.title.trim() || t('untitled')}</h3>
|
|
186
186
|
<DropdownMenu items={cardMenu(row, lane)}>
|
|
@@ -299,7 +299,8 @@ const laneRule = (lane: Lane) =>
|
|
|
299
299
|
color: var(--kern-ink-900);
|
|
300
300
|
}
|
|
301
301
|
.fields {
|
|
302
|
-
margin: 10px
|
|
302
|
+
margin-block-start: 10px;
|
|
303
|
+
margin-inline-end: 5px;
|
|
303
304
|
padding: 0;
|
|
304
305
|
display: flex;
|
|
305
306
|
flex-direction: column;
|
|
@@ -67,6 +67,19 @@ const api = getQuireApi()
|
|
|
67
67
|
const core = coreApi<CoreApi>()
|
|
68
68
|
const client = useQueryClient()
|
|
69
69
|
|
|
70
|
+
/** What a view with nothing configured looks like — the shape `ViewConfig` guarantees. */
|
|
71
|
+
const BLANK_CONFIG: ViewConfig = {
|
|
72
|
+
filters: [],
|
|
73
|
+
filterMode: 'and',
|
|
74
|
+
sorts: [],
|
|
75
|
+
groupBy: null,
|
|
76
|
+
dateProperty: null,
|
|
77
|
+
visibleProperties: null,
|
|
78
|
+
columnWidths: {},
|
|
79
|
+
cardSize: 'medium',
|
|
80
|
+
coverProperty: null,
|
|
81
|
+
}
|
|
82
|
+
|
|
70
83
|
const PAGE_SIZE = 50
|
|
71
84
|
/** What a non-paging view will show before it says it has stopped. */
|
|
72
85
|
const CAP = 500
|
|
@@ -155,10 +168,27 @@ $effect(() => {
|
|
|
155
168
|
if (inspecting && rows.length > 0 && !rows.some((r) => r.id === inspecting)) inspecting = null
|
|
156
169
|
})
|
|
157
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The configuration on screen: the last one *written*, not the last one read back.
|
|
173
|
+
*
|
|
174
|
+
* Two rapid edits both merge from `view.config`, and until the refetch lands that is the value from
|
|
175
|
+
* before either of them — so the second silently undoes the first. Holding the pending
|
|
176
|
+
* configuration here makes successive edits compose, and shows each one immediately rather than a
|
|
177
|
+
* round trip later.
|
|
178
|
+
*/
|
|
179
|
+
let pending = $state.raw<{ viewId: string; config: ViewConfig; seq: number } | null>(null)
|
|
180
|
+
let writes = 0
|
|
181
|
+
|
|
182
|
+
const liveConfig = $derived<ViewConfig>(
|
|
183
|
+
pending && pending.viewId === view?.id ? pending.config : (view?.config ?? BLANK_CONFIG),
|
|
184
|
+
)
|
|
185
|
+
/** The view as the screen should draw it: the stored one, wearing whatever was written last. */
|
|
186
|
+
const liveView = $derived<View | null>(view ? { ...view, config: liveConfig } : null)
|
|
187
|
+
|
|
158
188
|
const properties = $derived(database ? orderedProperties(database) : [])
|
|
159
189
|
const relations = $derived(properties.filter((p) => p.type === 'relation'))
|
|
160
190
|
const groupProperty = $derived(
|
|
161
|
-
|
|
191
|
+
liveConfig.groupBy ? (properties.find((p) => p.key === liveConfig.groupBy) ?? null) : null,
|
|
162
192
|
)
|
|
163
193
|
|
|
164
194
|
// ---- writing ------------------------------------------------------------------------------
|
|
@@ -170,36 +200,72 @@ const refreshSchema = async () => {
|
|
|
170
200
|
const refreshRows = () => client.invalidateQueries({ queryKey: ['quire', 'row', workspaceId, databaseId] })
|
|
171
201
|
|
|
172
202
|
/**
|
|
173
|
-
* One place that reports a failure, and one
|
|
203
|
+
* One place that reports a failure, and one guard that stops a second click.
|
|
204
|
+
*
|
|
205
|
+
* `disabled={busy}` alone does not stop it: the attribute reaches the button on the next render and
|
|
206
|
+
* two quick clicks are one render apart, so the guard is read here, in the same tick as the click.
|
|
174
207
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
208
|
+
* The guard is **per action**, not global. A single in-flight flag looks like the same thing and is
|
|
209
|
+
* not: choosing a filter's column, then its operator, then its value is three writes a few hundred
|
|
210
|
+
* milliseconds apart, and a global flag drops the second and the third on the floor — the panel
|
|
211
|
+
* fills in, the table never changes, and nothing on screen says why.
|
|
177
212
|
*/
|
|
178
|
-
|
|
179
|
-
|
|
213
|
+
const running = new Set<string>()
|
|
214
|
+
|
|
215
|
+
async function act(work: () => Promise<unknown>, after: () => Promise<unknown>, guard?: string) {
|
|
216
|
+
if (guard && running.has(guard)) return
|
|
217
|
+
if (guard) running.add(guard)
|
|
180
218
|
busy = true
|
|
181
219
|
try {
|
|
182
220
|
await work()
|
|
183
221
|
await after()
|
|
184
222
|
} catch (err) {
|
|
185
|
-
toast.error(err instanceof Error && err.message ? err.message : t('
|
|
223
|
+
toast.error(err instanceof Error && err.message ? err.message : t('error'))
|
|
186
224
|
} finally {
|
|
187
|
-
|
|
225
|
+
if (guard) running.delete(guard)
|
|
226
|
+
busy = running.size > 0
|
|
188
227
|
}
|
|
189
228
|
}
|
|
190
229
|
|
|
191
230
|
const patchView = (patch: Partial<ViewConfig>) => {
|
|
192
231
|
if (!view) return
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
232
|
+
/**
|
|
233
|
+
* `updateView` replaces `config` wholesale, so the merged whole always goes — a partial write
|
|
234
|
+
* deletes the rest of the view. And it is snapshotted: the merge carries arrays straight out of
|
|
235
|
+
* the query cache, which are `$state` proxies, and a proxy cannot be `structuredClone`d — which is
|
|
236
|
+
* what the API layer does, so the request throws before it is sent and the edit never appears.
|
|
237
|
+
*/
|
|
238
|
+
const config = $state.snapshot(mergeConfig(liveConfig, patch)) as ViewConfig
|
|
239
|
+
const viewId = view.id
|
|
240
|
+
const mine = ++writes
|
|
241
|
+
pending = { viewId, config, seq: mine }
|
|
242
|
+
/**
|
|
243
|
+
* Filtering, sorting and grouping decide **which rows** the server returns, and the rows query is
|
|
244
|
+
* keyed by the view's *id* — which does not change when its configuration does. Without this the
|
|
245
|
+
* filter panel fills in and the table underneath keeps showing everything, until something else
|
|
246
|
+
* happens to invalidate it.
|
|
247
|
+
*/
|
|
248
|
+
const changesRows =
|
|
249
|
+
patch.filters !== undefined || patch.filterMode !== undefined || patch.sorts !== undefined
|
|
250
|
+
void act(
|
|
251
|
+
() => api.databases.updateView({ workspaceId, viewId, config }),
|
|
252
|
+
async () => {
|
|
253
|
+
await refreshSchema()
|
|
254
|
+
if (changesRows) await refreshRows()
|
|
255
|
+
// Only the newest write hands control back to the server's answer.
|
|
256
|
+
if (pending?.seq === mine) pending = null
|
|
257
|
+
},
|
|
258
|
+
)
|
|
196
259
|
}
|
|
197
260
|
|
|
198
|
-
const writeCell = (row: Row, property: Property, value: unknown) =>
|
|
199
|
-
|
|
200
|
-
|
|
261
|
+
const writeCell = (row: Row, property: Property, value: unknown) => {
|
|
262
|
+
// A multi-select or relation cell hands back an array built from proxied state.
|
|
263
|
+
const next = $state.snapshot(value)
|
|
264
|
+
return act(
|
|
265
|
+
() => api.databases.updateRow({ workspaceId, rowId: row.id, props: { [property.key]: next } }),
|
|
201
266
|
refreshRows,
|
|
202
267
|
)
|
|
268
|
+
}
|
|
203
269
|
|
|
204
270
|
const writeTitle = (row: Row, title: string) => {
|
|
205
271
|
if (title === row.title) return Promise.resolve()
|
|
@@ -219,6 +285,7 @@ const addRow = (seed: Record<string, unknown> = {}) =>
|
|
|
219
285
|
await refreshRows()
|
|
220
286
|
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
|
|
221
287
|
},
|
|
288
|
+
'add-row',
|
|
222
289
|
)
|
|
223
290
|
|
|
224
291
|
const duplicateRow = (row: Row) =>
|
|
@@ -231,6 +298,7 @@ const duplicateRow = (row: Row) =>
|
|
|
231
298
|
props: $state.snapshot(row.props),
|
|
232
299
|
}),
|
|
233
300
|
refreshRows,
|
|
301
|
+
`duplicate-${row.id}`,
|
|
234
302
|
)
|
|
235
303
|
|
|
236
304
|
const openPage = (row: Row) =>
|
|
@@ -255,7 +323,7 @@ const moveOnBoard = (row: Row, laneId: string) => {
|
|
|
255
323
|
}
|
|
256
324
|
|
|
257
325
|
const setDate = (row: Row, iso: string | null) => {
|
|
258
|
-
const key =
|
|
326
|
+
const key = liveConfig.dateProperty
|
|
259
327
|
if (!key) return Promise.resolve()
|
|
260
328
|
return act(
|
|
261
329
|
() => api.databases.updateRow({ workspaceId, rowId: row.id, props: { [key]: iso } }),
|
|
@@ -299,19 +367,19 @@ const hideProperty = (property: Property) =>
|
|
|
299
367
|
|
|
300
368
|
const sortBy = (property: Property, direction: 'asc' | 'desc' | null) => {
|
|
301
369
|
if (!view) return
|
|
302
|
-
const rest =
|
|
370
|
+
const rest = liveConfig.sorts.filter((s) => s.propertyKey !== property.key)
|
|
303
371
|
patchView({ sorts: direction ? [{ propertyKey: property.key, direction }, ...rest] : rest })
|
|
304
372
|
}
|
|
305
373
|
|
|
306
374
|
const sortDirectionOf = (key: string) =>
|
|
307
|
-
|
|
375
|
+
liveConfig.sorts.find((s) => s.propertyKey === key)?.direction ?? null
|
|
308
376
|
|
|
309
377
|
function filterBy(property: Property) {
|
|
310
378
|
if (!view) return
|
|
311
|
-
const already =
|
|
379
|
+
const already = liveConfig.filters.some((f) => f.propertyKey === property.key)
|
|
312
380
|
if (!already) {
|
|
313
381
|
const operator = descriptorFor(property.type).operators[0] ?? 'equals'
|
|
314
|
-
patchView({ filters: [...
|
|
382
|
+
patchView({ filters: [...liveConfig.filters, { propertyKey: property.key, operator, value: null }] })
|
|
315
383
|
}
|
|
316
384
|
filterOpen = true
|
|
317
385
|
}
|
|
@@ -343,6 +411,7 @@ function confirmed() {
|
|
|
343
411
|
await refreshSchema()
|
|
344
412
|
await refreshRows()
|
|
345
413
|
},
|
|
414
|
+
`delete-property-${target.property.id}`,
|
|
346
415
|
)
|
|
347
416
|
else if (target.kind === 'view')
|
|
348
417
|
void act(
|
|
@@ -352,6 +421,7 @@ function confirmed() {
|
|
|
352
421
|
if (chosenViewId === target.view.id) chosenViewId = null
|
|
353
422
|
await refreshSchema()
|
|
354
423
|
},
|
|
424
|
+
`delete-view-${target.view.id}`,
|
|
355
425
|
)
|
|
356
426
|
else
|
|
357
427
|
void act(
|
|
@@ -362,6 +432,7 @@ function confirmed() {
|
|
|
362
432
|
await refreshRows()
|
|
363
433
|
await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, spaceId) })
|
|
364
434
|
},
|
|
435
|
+
`delete-row-${target.row.id}`,
|
|
365
436
|
)
|
|
366
437
|
}
|
|
367
438
|
|
|
@@ -389,7 +460,7 @@ const confirmBody = $derived(
|
|
|
389
460
|
const groupItems = $derived<MenuItem[]>([
|
|
390
461
|
{
|
|
391
462
|
type: 'radio',
|
|
392
|
-
value:
|
|
463
|
+
value: liveConfig.groupBy ?? '',
|
|
393
464
|
options: [
|
|
394
465
|
{ value: '', label: t('db_group_none') },
|
|
395
466
|
...properties
|
|
@@ -469,7 +540,7 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
|
|
|
469
540
|
void rowsQuery.refetch()
|
|
470
541
|
}}
|
|
471
542
|
>
|
|
472
|
-
{t('
|
|
543
|
+
{t('retry')}
|
|
473
544
|
</Button>
|
|
474
545
|
{/snippet}
|
|
475
546
|
</EmptyState>
|
|
@@ -500,7 +571,7 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
|
|
|
500
571
|
<Toolbar>
|
|
501
572
|
<FilterMenu
|
|
502
573
|
{database}
|
|
503
|
-
config={
|
|
574
|
+
config={liveConfig}
|
|
504
575
|
{workspaceId}
|
|
505
576
|
{people}
|
|
506
577
|
{canEdit}
|
|
@@ -510,16 +581,16 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
|
|
|
510
581
|
/>
|
|
511
582
|
<SortMenu
|
|
512
583
|
{database}
|
|
513
|
-
config={
|
|
584
|
+
config={liveConfig}
|
|
514
585
|
{canEdit}
|
|
515
586
|
open={sortOpen}
|
|
516
587
|
onOpenChange={(o) => (sortOpen = o)}
|
|
517
588
|
onchange={patchView}
|
|
518
589
|
/>
|
|
519
|
-
{#if
|
|
590
|
+
{#if liveView?.kind === 'board'}
|
|
520
591
|
<DropdownMenu items={groupItems} align="start">
|
|
521
592
|
{#snippet trigger(props: Record<string, unknown>)}
|
|
522
|
-
<ToolbarButton {...props} prefix={t('db_by')} active={Boolean(
|
|
593
|
+
<ToolbarButton {...props} prefix={t('db_by')} active={Boolean(liveConfig.groupBy)}>
|
|
523
594
|
{groupProperty?.name ?? t('db_group_none')}
|
|
524
595
|
</ToolbarButton>
|
|
525
596
|
{/snippet}
|
|
@@ -561,7 +632,7 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
|
|
|
561
632
|
{/if}
|
|
562
633
|
<TableView
|
|
563
634
|
{database}
|
|
564
|
-
{
|
|
635
|
+
view={liveView}
|
|
565
636
|
{rows}
|
|
566
637
|
{people}
|
|
567
638
|
{workspaceId}
|
|
@@ -591,14 +662,14 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
|
|
|
591
662
|
disabled={rowsQuery.isFetchingNextPage}
|
|
592
663
|
onclick={() => void rowsQuery.fetchNextPage()}
|
|
593
664
|
>
|
|
594
|
-
{rowsQuery.isFetchingNextPage ? t('
|
|
665
|
+
{rowsQuery.isFetchingNextPage ? t('loading') : t('db_load_more')}
|
|
595
666
|
</Button>
|
|
596
667
|
</div>
|
|
597
668
|
{/if}
|
|
598
669
|
{:else if view.kind === 'board'}
|
|
599
670
|
<BoardView
|
|
600
671
|
{database}
|
|
601
|
-
{
|
|
672
|
+
view={liveView}
|
|
602
673
|
{rows}
|
|
603
674
|
{people}
|
|
604
675
|
{workspaceId}
|
|
@@ -619,7 +690,7 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
|
|
|
619
690
|
{:else if view.kind === 'gallery'}
|
|
620
691
|
<GalleryView
|
|
621
692
|
{database}
|
|
622
|
-
{
|
|
693
|
+
view={liveView}
|
|
623
694
|
{rows}
|
|
624
695
|
{people}
|
|
625
696
|
{workspaceId}
|
|
@@ -631,7 +702,7 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
|
|
|
631
702
|
{:else if view.kind === 'list'}
|
|
632
703
|
<ListView
|
|
633
704
|
{database}
|
|
634
|
-
{
|
|
705
|
+
view={liveView}
|
|
635
706
|
{rows}
|
|
636
707
|
{people}
|
|
637
708
|
{workspaceId}
|
|
@@ -643,7 +714,7 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
|
|
|
643
714
|
{:else}
|
|
644
715
|
<CalendarView
|
|
645
716
|
{database}
|
|
646
|
-
{
|
|
717
|
+
view={liveView}
|
|
647
718
|
{rows}
|
|
648
719
|
{canEdit}
|
|
649
720
|
onOpenRow={(row) => (inspecting = row.id)}
|
|
@@ -707,8 +778,8 @@ const failed = $derived(forPage.isError || databaseQuery.isError || rowsQuery.is
|
|
|
707
778
|
>
|
|
708
779
|
<p class="confirm">{confirmBody}</p>
|
|
709
780
|
{#snippet footer()}
|
|
710
|
-
<Button variant="secondary" onclick={() => (confirming = null)}>{t('
|
|
711
|
-
<Button variant="danger" disabled={busy} onclick={confirmed}>{t('
|
|
781
|
+
<Button variant="secondary" onclick={() => (confirming = null)}>{t('cancel')}</Button>
|
|
782
|
+
<Button variant="danger" disabled={busy} onclick={confirmed}>{t('delete')}</Button>
|
|
712
783
|
{/snippet}
|
|
713
784
|
</Dialog>
|
|
714
785
|
|
|
@@ -87,7 +87,7 @@ const formulaError = $derived.by(() => {
|
|
|
87
87
|
parseFormula(expression)
|
|
88
88
|
return null
|
|
89
89
|
} catch (err) {
|
|
90
|
-
return err instanceof FormulaError || err instanceof Error ? err.message : t('
|
|
90
|
+
return err instanceof FormulaError || err instanceof Error ? err.message : t('error')
|
|
91
91
|
}
|
|
92
92
|
})
|
|
93
93
|
|
|
@@ -362,9 +362,9 @@ $effect(() => {
|
|
|
362
362
|
</div>
|
|
363
363
|
|
|
364
364
|
{#snippet footer()}
|
|
365
|
-
<Button variant="secondary" onclick={onClose}>{t('
|
|
365
|
+
<Button variant="secondary" onclick={onClose}>{t('cancel')}</Button>
|
|
366
366
|
<Button disabled={!valid || busy || submitting} onclick={submit}>
|
|
367
|
-
{property ? t('
|
|
367
|
+
{property ? t('save') : t('add')}
|
|
368
368
|
</Button>
|
|
369
369
|
{/snippet}
|
|
370
370
|
</Dialog>
|
|
@@ -158,7 +158,7 @@ const rowMenu = (row: Row): MenuItem[] => [
|
|
|
158
158
|
</script>
|
|
159
159
|
|
|
160
160
|
<div class="scroll">
|
|
161
|
-
<div class="rail" style:min-width="{minWidth}px">
|
|
161
|
+
<div class="rail" data-testid="database-table" style:min-width="{minWidth}px">
|
|
162
162
|
<Table columns={liveTemplate} dense>
|
|
163
163
|
<TableHeader>
|
|
164
164
|
<TableCell header>
|
|
@@ -206,7 +206,7 @@ const rowMenu = (row: Row): MenuItem[] => [
|
|
|
206
206
|
</TableHeader>
|
|
207
207
|
|
|
208
208
|
{#each rows as row (row.id)}
|
|
209
|
-
<TableRow>
|
|
209
|
+
<TableRow data-testid="database-row" data-row-id={row.id}>
|
|
210
210
|
<TableCell class="title-cell">
|
|
211
211
|
{#if canEdit}
|
|
212
212
|
<input
|
|
@@ -172,9 +172,9 @@ $effect(() => {
|
|
|
172
172
|
</div>
|
|
173
173
|
|
|
174
174
|
{#snippet footer()}
|
|
175
|
-
<Button variant="secondary" onclick={onClose}>{t('
|
|
175
|
+
<Button variant="secondary" onclick={onClose}>{t('cancel')}</Button>
|
|
176
176
|
<Button disabled={!valid || busy || submitting} onclick={submit}>
|
|
177
|
-
{view ? t('
|
|
177
|
+
{view ? t('save') : t('create')}
|
|
178
178
|
</Button>
|
|
179
179
|
{/snippet}
|
|
180
180
|
</Dialog>
|
|
@@ -108,7 +108,7 @@ const unlink = (id: string) => onchange(ids.filter((v) => v !== id))
|
|
|
108
108
|
{#if search.isLoading}
|
|
109
109
|
<div class="state"><Spinner size={16} /></div>
|
|
110
110
|
{:else if search.isError}
|
|
111
|
-
<p class="state">{t('
|
|
111
|
+
<p class="state">{t('error')}</p>
|
|
112
112
|
{:else if results.length === 0}
|
|
113
113
|
<p class="state">{t('db_relation_none')}</p>
|
|
114
114
|
{:else}
|
package/src/client/i18n.ts
CHANGED
|
@@ -325,6 +325,14 @@ export const en: Record<string, Message> = {
|
|
|
325
325
|
'quire.db_views': 'Views',
|
|
326
326
|
'quire.db_visible_properties': 'Columns shown',
|
|
327
327
|
'quire.db_widen': 'Widen',
|
|
328
|
+
'quire.add': 'Add',
|
|
329
|
+
'quire.cancel': 'Cancel',
|
|
330
|
+
'quire.create': 'Create',
|
|
331
|
+
'quire.delete': 'Delete',
|
|
332
|
+
'quire.error': 'Something went wrong. Try again.',
|
|
333
|
+
'quire.loading': 'Loading…',
|
|
334
|
+
'quire.retry': 'Try again',
|
|
335
|
+
'quire.save': 'Save',
|
|
328
336
|
}
|
|
329
337
|
|
|
330
338
|
export type QuireMessageKey = keyof typeof en
|
|
@@ -657,6 +665,14 @@ const ar: Record<string, Message> = {
|
|
|
657
665
|
'quire.db_views': 'طرق العرض',
|
|
658
666
|
'quire.db_visible_properties': 'الأعمدة المعروضة',
|
|
659
667
|
'quire.db_widen': 'توسيع',
|
|
668
|
+
'quire.add': 'إضافة',
|
|
669
|
+
'quire.cancel': 'إلغاء',
|
|
670
|
+
'quire.create': 'إنشاء',
|
|
671
|
+
'quire.delete': 'حذف',
|
|
672
|
+
'quire.error': 'حدث خطأ ما. حاول مرة أخرى.',
|
|
673
|
+
'quire.loading': 'جارٍ التحميل…',
|
|
674
|
+
'quire.retry': 'أعد المحاولة',
|
|
675
|
+
'quire.save': 'حفظ',
|
|
660
676
|
}
|
|
661
677
|
|
|
662
678
|
const de: Record<string, Message> = {
|
|
@@ -978,6 +994,14 @@ const de: Record<string, Message> = {
|
|
|
978
994
|
'quire.db_views': 'Ansichten',
|
|
979
995
|
'quire.db_visible_properties': 'Sichtbare Spalten',
|
|
980
996
|
'quire.db_widen': 'Verbreitern',
|
|
997
|
+
'quire.add': 'Hinzufügen',
|
|
998
|
+
'quire.cancel': 'Abbrechen',
|
|
999
|
+
'quire.create': 'Erstellen',
|
|
1000
|
+
'quire.delete': 'Löschen',
|
|
1001
|
+
'quire.error': 'Etwas ist schiefgelaufen. Versuche es erneut.',
|
|
1002
|
+
'quire.loading': 'Wird geladen…',
|
|
1003
|
+
'quire.retry': 'Erneut versuchen',
|
|
1004
|
+
'quire.save': 'Speichern',
|
|
981
1005
|
}
|
|
982
1006
|
|
|
983
1007
|
const fa: Record<string, Message> = {
|
|
@@ -1290,6 +1314,14 @@ const fa: Record<string, Message> = {
|
|
|
1290
1314
|
'quire.db_views': 'نماها',
|
|
1291
1315
|
'quire.db_visible_properties': 'ستونهای نمایان',
|
|
1292
1316
|
'quire.db_widen': 'پهنتر',
|
|
1317
|
+
'quire.add': 'افزودن',
|
|
1318
|
+
'quire.cancel': 'انصراف',
|
|
1319
|
+
'quire.create': 'ساختن',
|
|
1320
|
+
'quire.delete': 'حذف',
|
|
1321
|
+
'quire.error': 'چیزی درست پیش نرفت. دوباره تلاش کنید.',
|
|
1322
|
+
'quire.loading': 'در حال بارگذاری…',
|
|
1323
|
+
'quire.retry': 'دوباره تلاش کنید',
|
|
1324
|
+
'quire.save': 'ذخیره',
|
|
1293
1325
|
}
|
|
1294
1326
|
|
|
1295
1327
|
const tr: Record<string, Message> = {
|
|
@@ -1604,6 +1636,14 @@ const tr: Record<string, Message> = {
|
|
|
1604
1636
|
'quire.db_views': 'Görünümler',
|
|
1605
1637
|
'quire.db_visible_properties': 'Görünen sütunlar',
|
|
1606
1638
|
'quire.db_widen': 'Genişlet',
|
|
1639
|
+
'quire.add': 'Ekle',
|
|
1640
|
+
'quire.cancel': 'Vazgeç',
|
|
1641
|
+
'quire.create': 'Oluştur',
|
|
1642
|
+
'quire.delete': 'Sil',
|
|
1643
|
+
'quire.error': 'Bir şeyler ters gitti. Yeniden deneyin.',
|
|
1644
|
+
'quire.loading': 'Yükleniyor…',
|
|
1645
|
+
'quire.retry': 'Yeniden dene',
|
|
1646
|
+
'quire.save': 'Kaydet',
|
|
1607
1647
|
}
|
|
1608
1648
|
|
|
1609
1649
|
/** In the shape `defineClientModule().messages` expects. */
|
package/src/client/mock.ts
CHANGED
|
@@ -281,6 +281,17 @@ export function createMockQuireApi() {
|
|
|
281
281
|
throw notFound('View')
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
/**
|
|
285
|
+
* A copy on the way out, because a real API answers with fresh JSON every time.
|
|
286
|
+
*
|
|
287
|
+
* Handing back the live object makes the mock *look* right and behave wrongly in one specific
|
|
288
|
+
* way: TanStack's structural sharing compares the new answer with the cached one, finds the same
|
|
289
|
+
* object, and keeps the old reference — so `query.data` never changes identity, no `$derived`
|
|
290
|
+
* re-runs, and every schema change (a new column, a deleted view) is applied to the data and
|
|
291
|
+
* invisible on screen. Rows never showed it, because `toDatabaseRow` already built a new object.
|
|
292
|
+
*/
|
|
293
|
+
const copy = <T>(value: T): T => structuredClone(value)
|
|
294
|
+
|
|
284
295
|
const toDatabaseRow = (row: Row): DatabaseRow => ({
|
|
285
296
|
id: row.id,
|
|
286
297
|
databaseId: row._databaseId ?? '',
|
|
@@ -558,9 +569,12 @@ export function createMockQuireApi() {
|
|
|
558
569
|
.filter((d) => d.spaceId === spaceId)
|
|
559
570
|
.map((d) => ({ id: d.id, pageId: d.pageId, name: d.name })),
|
|
560
571
|
|
|
561
|
-
get: async ({ databaseId }: { databaseId: string }) => theDatabase(databaseId),
|
|
572
|
+
get: async ({ databaseId }: { databaseId: string }) => copy(theDatabase(databaseId)),
|
|
562
573
|
|
|
563
|
-
forPage: async ({ pageId }: { pageId: string }) =>
|
|
574
|
+
forPage: async ({ pageId }: { pageId: string }) => {
|
|
575
|
+
const db = databases.find((d) => d.pageId === pageId)
|
|
576
|
+
return db ? copy(db) : null
|
|
577
|
+
},
|
|
564
578
|
|
|
565
579
|
create: async (input: { spaceId: string; pageId: string; name?: string; inline?: boolean }) => {
|
|
566
580
|
const db: Database = {
|
|
@@ -589,7 +603,7 @@ export function createMockQuireApi() {
|
|
|
589
603
|
for (const view of db.views) view.databaseId = db.id
|
|
590
604
|
databases.push(db)
|
|
591
605
|
found(input.pageId).kind = 'database'
|
|
592
|
-
return db
|
|
606
|
+
return copy(db)
|
|
593
607
|
},
|
|
594
608
|
|
|
595
609
|
rows: async (input: {
|
|
@@ -702,7 +716,7 @@ export function createMockQuireApi() {
|
|
|
702
716
|
hidden: false,
|
|
703
717
|
}
|
|
704
718
|
db.properties.push(property)
|
|
705
|
-
return property
|
|
719
|
+
return copy(property)
|
|
706
720
|
},
|
|
707
721
|
|
|
708
722
|
updateProperty: async (input: {
|
|
@@ -717,7 +731,7 @@ export function createMockQuireApi() {
|
|
|
717
731
|
if (input.type !== undefined) property.type = input.type
|
|
718
732
|
if (input.config !== undefined) property.config = input.config
|
|
719
733
|
if (input.hidden !== undefined) property.hidden = input.hidden
|
|
720
|
-
return property
|
|
734
|
+
return copy(property)
|
|
721
735
|
},
|
|
722
736
|
|
|
723
737
|
moveProperty: async (input: { propertyId: string; afterId?: string | null }) => {
|
|
@@ -732,7 +746,7 @@ export function createMockQuireApi() {
|
|
|
732
746
|
db.properties.forEach((p, index) => {
|
|
733
747
|
p.position = String(index).padStart(4, '0')
|
|
734
748
|
})
|
|
735
|
-
return property
|
|
749
|
+
return copy(property)
|
|
736
750
|
},
|
|
737
751
|
|
|
738
752
|
removeProperty: async (input: { propertyId: string }) => {
|
|
@@ -759,7 +773,7 @@ export function createMockQuireApi() {
|
|
|
759
773
|
isDefault: db.views.length === 0,
|
|
760
774
|
}
|
|
761
775
|
db.views.push(view)
|
|
762
|
-
return view
|
|
776
|
+
return copy(view)
|
|
763
777
|
},
|
|
764
778
|
|
|
765
779
|
updateView: async (input: {
|
|
@@ -773,7 +787,7 @@ export function createMockQuireApi() {
|
|
|
773
787
|
if (input.kind !== undefined) view.kind = input.kind
|
|
774
788
|
// Replaced wholesale, exactly as the service does it — the client must send the merged whole.
|
|
775
789
|
if (input.config !== undefined) view.config = { ...BLANK_VIEW_CONFIG, ...input.config }
|
|
776
|
-
return view
|
|
790
|
+
return copy(view)
|
|
777
791
|
},
|
|
778
792
|
|
|
779
793
|
removeView: async (input: { viewId: string }) => {
|
|
@@ -189,35 +189,42 @@ async function trash() {
|
|
|
189
189
|
{:else if query.isError}
|
|
190
190
|
<EmptyState icon="triangle-alert" title={t('page_error')} description={t('page_error_desc')}>
|
|
191
191
|
{#snippet actions()}
|
|
192
|
-
<Button variant="secondary" onclick={() => void query.refetch()}>{t('
|
|
192
|
+
<Button variant="secondary" onclick={() => void query.refetch()}>{t('retry')}</Button>
|
|
193
193
|
{/snippet}
|
|
194
194
|
</EmptyState>
|
|
195
195
|
{:else if !doc}
|
|
196
196
|
<EmptyState icon="circle-help" title={t('page_missing')} description={t('page_missing_desc')} />
|
|
197
197
|
{:else}
|
|
198
198
|
<div class="head">
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
e.
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
199
|
+
<!--
|
|
200
|
+
The editable title is *inside* the h1 rather than instead of it. A page whose only title is
|
|
201
|
+
an `<input>` has no level-1 heading at all — which is what a screen reader looks for first,
|
|
202
|
+
and what `ux.spec.ts` fails a route on.
|
|
203
|
+
-->
|
|
204
|
+
<h1 class="title">
|
|
205
|
+
{#if editable}
|
|
206
|
+
<input
|
|
207
|
+
bind:this={titleEl}
|
|
208
|
+
class="title-field"
|
|
209
|
+
value={title}
|
|
210
|
+
placeholder={t('untitled')}
|
|
211
|
+
aria-label={t('page_title')}
|
|
212
|
+
oninput={(e) => {
|
|
213
|
+
title = (e.currentTarget as HTMLInputElement).value
|
|
214
|
+
dirty = true
|
|
215
|
+
}}
|
|
216
|
+
onblur={saveTitle}
|
|
217
|
+
onkeydown={(e) => {
|
|
218
|
+
if (e.key === 'Enter') {
|
|
219
|
+
e.preventDefault()
|
|
220
|
+
;(e.currentTarget as HTMLInputElement).blur()
|
|
221
|
+
}
|
|
222
|
+
}}
|
|
223
|
+
/>
|
|
224
|
+
{:else}
|
|
225
|
+
{doc.title.trim() || t('untitled')}
|
|
226
|
+
{/if}
|
|
227
|
+
</h1>
|
|
221
228
|
|
|
222
229
|
<DropdownMenu
|
|
223
230
|
items={[
|
|
@@ -356,12 +363,18 @@ async function trash() {
|
|
|
356
363
|
line-height: 1.2;
|
|
357
364
|
color: var(--kern-ink-900);
|
|
358
365
|
margin: 0;
|
|
366
|
+
}
|
|
367
|
+
.title-field {
|
|
368
|
+
width: 100%;
|
|
369
|
+
min-width: 0;
|
|
370
|
+
color: inherit;
|
|
371
|
+
font: inherit;
|
|
372
|
+
margin: 0;
|
|
359
373
|
border: 0;
|
|
360
374
|
background: none;
|
|
361
375
|
padding: 0;
|
|
362
|
-
font-family: inherit;
|
|
363
376
|
}
|
|
364
|
-
.title:focus {
|
|
377
|
+
.title-field:focus {
|
|
365
378
|
outline: none;
|
|
366
379
|
}
|
|
367
380
|
.byline {
|
|
@@ -54,7 +54,7 @@ function open(key: string) {
|
|
|
54
54
|
{:else if spacesQuery.isError}
|
|
55
55
|
<EmptyState icon="triangle-alert" title={t('spaces_error')} description={t('spaces_error_desc')}>
|
|
56
56
|
{#snippet actions()}
|
|
57
|
-
<Button variant="secondary" onclick={() => void spacesQuery.refetch()}>{t('
|
|
57
|
+
<Button variant="secondary" onclick={() => void spacesQuery.refetch()}>{t('retry')}</Button>
|
|
58
58
|
{/snippet}
|
|
59
59
|
</EmptyState>
|
|
60
60
|
{:else if spaceList.length === 0}
|