@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,108 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { DropdownMenu, Icon, IconButton, ListRow, type MenuItem } from '@kernhq/ui'
|
|
3
|
+
import type { Database, Row, View } from '../../contract/index.js'
|
|
4
|
+
import type { Person } from '../core-api.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import Cell from './cells/Cell.svelte'
|
|
7
|
+
import { visiblePropertiesOf } from './view-config.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One row per row: the title, then two or three columns as trailing values.
|
|
11
|
+
*
|
|
12
|
+
* `ListRow` is given neither `href` nor `onclick` — either turns it into a link or a button, and
|
|
13
|
+
* the actions live inside it. Opening is the explicit action at the end, as in the table.
|
|
14
|
+
*/
|
|
15
|
+
interface Props {
|
|
16
|
+
database: Database
|
|
17
|
+
view: View | null
|
|
18
|
+
rows: Row[]
|
|
19
|
+
people: Person[]
|
|
20
|
+
workspaceId: string
|
|
21
|
+
canEdit: boolean
|
|
22
|
+
onOpenRow: (row: Row) => void
|
|
23
|
+
onOpenPage: (row: Row) => void
|
|
24
|
+
onDeleteRow: (row: Row) => void
|
|
25
|
+
}
|
|
26
|
+
const { database, view, rows, people, workspaceId, canEdit, onOpenRow, onOpenPage, onDeleteRow }: Props =
|
|
27
|
+
$props()
|
|
28
|
+
|
|
29
|
+
const columns = $derived(visiblePropertiesOf(database, view).slice(0, 3))
|
|
30
|
+
|
|
31
|
+
const menu = (row: Row): MenuItem[] => [
|
|
32
|
+
{ id: 'open', label: t('db_open_row'), icon: 'maximize-2', onSelect: () => onOpenRow(row) },
|
|
33
|
+
{ id: 'page', label: t('db_open_as_page'), icon: 'file-text', onSelect: () => onOpenPage(row) },
|
|
34
|
+
{ type: 'separator' },
|
|
35
|
+
{
|
|
36
|
+
id: 'delete',
|
|
37
|
+
label: t('db_delete_row'),
|
|
38
|
+
icon: 'trash-2',
|
|
39
|
+
danger: true,
|
|
40
|
+
disabled: !canEdit,
|
|
41
|
+
onSelect: () => onDeleteRow(row),
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<div class="list">
|
|
47
|
+
{#each rows as row (row.id)}
|
|
48
|
+
<ListRow>
|
|
49
|
+
{#snippet leading()}
|
|
50
|
+
<Icon name="file-text" size={14} strokeWidth={1.6} />
|
|
51
|
+
{/snippet}
|
|
52
|
+
{#snippet title()}
|
|
53
|
+
{row.title.trim() || t('untitled')}
|
|
54
|
+
{/snippet}
|
|
55
|
+
{#snippet meta()}
|
|
56
|
+
{#each columns as property (property.id)}
|
|
57
|
+
<span class="meta-cell" title={property.name}>
|
|
58
|
+
<Cell {property} {row} {people} {workspaceId} canEdit={false} onchange={() => {}} />
|
|
59
|
+
</span>
|
|
60
|
+
{/each}
|
|
61
|
+
{/snippet}
|
|
62
|
+
{#snippet trailing()}
|
|
63
|
+
<span class="actions">
|
|
64
|
+
<IconButton
|
|
65
|
+
icon="maximize-2"
|
|
66
|
+
label={t('db_open_row_named', { title: row.title.trim() || t('untitled') })}
|
|
67
|
+
size={26}
|
|
68
|
+
variant="ghost"
|
|
69
|
+
onclick={() => onOpenRow(row)}
|
|
70
|
+
/>
|
|
71
|
+
<DropdownMenu items={menu(row)}>
|
|
72
|
+
{#snippet trigger(props: Record<string, unknown>)}
|
|
73
|
+
<IconButton
|
|
74
|
+
{...props}
|
|
75
|
+
icon="ellipsis"
|
|
76
|
+
label={t('db_row_actions', { title: row.title.trim() || t('untitled') })}
|
|
77
|
+
size={26}
|
|
78
|
+
variant="ghost"
|
|
79
|
+
/>
|
|
80
|
+
{/snippet}
|
|
81
|
+
</DropdownMenu>
|
|
82
|
+
</span>
|
|
83
|
+
{/snippet}
|
|
84
|
+
</ListRow>
|
|
85
|
+
{/each}
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
<style>
|
|
89
|
+
.list {
|
|
90
|
+
padding-block-end: 40px;
|
|
91
|
+
}
|
|
92
|
+
.meta-cell {
|
|
93
|
+
display: inline-flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
max-width: 200px;
|
|
96
|
+
min-width: 0;
|
|
97
|
+
overflow: hidden;
|
|
98
|
+
}
|
|
99
|
+
.actions {
|
|
100
|
+
display: inline-flex;
|
|
101
|
+
gap: 2px;
|
|
102
|
+
opacity: 0;
|
|
103
|
+
}
|
|
104
|
+
:global(.krow:hover) .actions,
|
|
105
|
+
:global(.krow:focus-within) .actions {
|
|
106
|
+
opacity: 1;
|
|
107
|
+
}
|
|
108
|
+
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { SelectOption } from '../../contract/index.js'
|
|
3
|
+
import { toneFor } from './colours.js'
|
|
4
|
+
|
|
5
|
+
/** One select, status or multi-select value, painted from the closed tone map in `colours.ts`. */
|
|
6
|
+
interface Props {
|
|
7
|
+
option?: SelectOption | null
|
|
8
|
+
/** what to draw when the option itself is gone — the raw id is better than an empty cell */
|
|
9
|
+
label?: string
|
|
10
|
+
colour?: string
|
|
11
|
+
compact?: boolean
|
|
12
|
+
}
|
|
13
|
+
const { option = null, label, colour, compact = false }: Props = $props()
|
|
14
|
+
|
|
15
|
+
const tone = $derived(toneFor(colour ?? option?.colour))
|
|
16
|
+
const text = $derived(label ?? option?.label ?? '')
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<span class="chip" class:compact style:background={tone.bg} style:color={tone.fg}>{text}</span>
|
|
20
|
+
|
|
21
|
+
<style>
|
|
22
|
+
.chip {
|
|
23
|
+
display: inline-flex;
|
|
24
|
+
align-items: center;
|
|
25
|
+
max-width: 100%;
|
|
26
|
+
padding: 3px 9px;
|
|
27
|
+
border-radius: var(--kern-r-md);
|
|
28
|
+
font-size: 12px;
|
|
29
|
+
line-height: 1.35;
|
|
30
|
+
white-space: nowrap;
|
|
31
|
+
overflow: hidden;
|
|
32
|
+
text-overflow: ellipsis;
|
|
33
|
+
}
|
|
34
|
+
.chip.compact {
|
|
35
|
+
padding: 1px 7px;
|
|
36
|
+
font-size: 11.5px;
|
|
37
|
+
}
|
|
38
|
+
</style>
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Dialog, Field, Icon, Input, Select, Switch } from '@kernhq/ui'
|
|
3
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import type {
|
|
5
|
+
DatabaseRef,
|
|
6
|
+
Property,
|
|
7
|
+
PropertyConfig,
|
|
8
|
+
PropertyType,
|
|
9
|
+
RollupFunction,
|
|
10
|
+
SelectOption,
|
|
11
|
+
} from '../../contract/index.js'
|
|
12
|
+
import { getQuireApi } from '../api-instance.js'
|
|
13
|
+
import { FormulaError, parseFormula } from '../formula.js'
|
|
14
|
+
import { t } from '../i18n.js'
|
|
15
|
+
import { quireKeys } from '../query.js'
|
|
16
|
+
import { OPTION_COLOURS, toneFor } from './colours.js'
|
|
17
|
+
import { CREATABLE_TYPES, descriptorFor } from './property-types.js'
|
|
18
|
+
import { STATUS_GROUPS } from './view-config.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Add a column, or change one.
|
|
22
|
+
*
|
|
23
|
+
* Only the configuration the chosen type actually uses is shown — a select's options are not a
|
|
24
|
+
* question you can answer about a number, and a form full of inert fields is how a person learns to
|
|
25
|
+
* stop reading it.
|
|
26
|
+
*
|
|
27
|
+
* The formula is validated **here**, as it is typed, by the same parser the server evaluates with.
|
|
28
|
+
* That is why `formula.ts` moved into `src/client`: a round trip per keystroke is not a validator,
|
|
29
|
+
* and a column saved with a broken expression is a table of error chips.
|
|
30
|
+
*/
|
|
31
|
+
interface Props {
|
|
32
|
+
open: boolean
|
|
33
|
+
workspaceId: string
|
|
34
|
+
spaceId: string
|
|
35
|
+
databaseId: string
|
|
36
|
+
/** null when adding */
|
|
37
|
+
property: Property | null
|
|
38
|
+
/** this database's relation columns, which a rollup walks */
|
|
39
|
+
relations: Property[]
|
|
40
|
+
busy: boolean
|
|
41
|
+
onClose: () => void
|
|
42
|
+
onSubmit: (input: { name: string; type: PropertyType; config: PropertyConfig }) => void
|
|
43
|
+
}
|
|
44
|
+
const { open, workspaceId, spaceId, databaseId, property, relations, busy, onClose, onSubmit }: Props =
|
|
45
|
+
$props()
|
|
46
|
+
|
|
47
|
+
const api = getQuireApi()
|
|
48
|
+
|
|
49
|
+
let name = $state('')
|
|
50
|
+
let type = $state<PropertyType>('text')
|
|
51
|
+
let config = $state<PropertyConfig>({})
|
|
52
|
+
let submitting = $state(false)
|
|
53
|
+
|
|
54
|
+
/** Reset every time the dialog opens, so a cancelled edit never leaks into the next one. */
|
|
55
|
+
$effect(() => {
|
|
56
|
+
if (!open) return
|
|
57
|
+
name = property?.name ?? ''
|
|
58
|
+
type = property?.type ?? 'text'
|
|
59
|
+
config = { ...(property?.config ?? {}) }
|
|
60
|
+
submitting = false
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const descriptor = $derived(descriptorFor(type))
|
|
64
|
+
|
|
65
|
+
/** The databases a relation may point at, and the one a rollup reads through. */
|
|
66
|
+
const databasesQuery = createQuery(() => ({
|
|
67
|
+
queryKey: ['quire', 'database', workspaceId, 'space', spaceId],
|
|
68
|
+
enabled: open && Boolean(workspaceId && spaceId) && (type === 'relation' || type === 'rollup'),
|
|
69
|
+
queryFn: () => api.databases.list({ workspaceId, spaceId }),
|
|
70
|
+
}))
|
|
71
|
+
const targets = $derived<DatabaseRef[]>(databasesQuery.data ?? [])
|
|
72
|
+
|
|
73
|
+
/** A rollup gathers a column from the database on the *other* side of the relation it walks. */
|
|
74
|
+
const viaProperty = $derived(relations.find((r) => r.id === config.rollupRelationPropertyId) ?? null)
|
|
75
|
+
const viaDatabaseId = $derived(viaProperty?.config.relationDatabaseId ?? null)
|
|
76
|
+
const viaDatabase = createQuery(() => ({
|
|
77
|
+
queryKey: quireKeys.database(workspaceId, viaDatabaseId ?? ''),
|
|
78
|
+
enabled: open && type === 'rollup' && Boolean(viaDatabaseId),
|
|
79
|
+
queryFn: () => api.databases.get({ workspaceId, databaseId: viaDatabaseId! }),
|
|
80
|
+
}))
|
|
81
|
+
|
|
82
|
+
const formulaError = $derived.by(() => {
|
|
83
|
+
if (type !== 'formula') return null
|
|
84
|
+
const expression = (config.expression ?? '').trim()
|
|
85
|
+
if (!expression) return null
|
|
86
|
+
try {
|
|
87
|
+
parseFormula(expression)
|
|
88
|
+
return null
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return err instanceof FormulaError || err instanceof Error ? err.message : t('common.error')
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const options = $derived<SelectOption[]>(config.options ?? [])
|
|
95
|
+
const needsOptions = $derived(type === 'select' || type === 'multi_select' || type === 'status')
|
|
96
|
+
|
|
97
|
+
const setConfig = (patch: Partial<PropertyConfig>) => {
|
|
98
|
+
config = { ...config, ...patch }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A stable id, because the option id is what every row already stores. */
|
|
102
|
+
const nextOptionId = () => `opt_${Math.random().toString(36).slice(2, 9)}`
|
|
103
|
+
|
|
104
|
+
const addOption = () =>
|
|
105
|
+
setConfig({
|
|
106
|
+
options: [
|
|
107
|
+
...options,
|
|
108
|
+
{
|
|
109
|
+
id: nextOptionId(),
|
|
110
|
+
label: t('db_option_new'),
|
|
111
|
+
colour: OPTION_COLOURS[options.length % OPTION_COLOURS.length] ?? 'grey',
|
|
112
|
+
...(type === 'status' ? { group: 'todo' as const } : {}),
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const patchOption = (id: string, patch: Partial<SelectOption>) =>
|
|
118
|
+
setConfig({ options: options.map((o) => (o.id === id ? { ...o, ...patch } : o)) })
|
|
119
|
+
|
|
120
|
+
const removeOption = (id: string) => setConfig({ options: options.filter((o) => o.id !== id) })
|
|
121
|
+
|
|
122
|
+
const ROLLUP_FUNCTIONS: RollupFunction[] = [
|
|
123
|
+
'count',
|
|
124
|
+
'count_values',
|
|
125
|
+
'count_unique',
|
|
126
|
+
'sum',
|
|
127
|
+
'average',
|
|
128
|
+
'min',
|
|
129
|
+
'max',
|
|
130
|
+
'range',
|
|
131
|
+
'show_original',
|
|
132
|
+
'checked',
|
|
133
|
+
'unchecked',
|
|
134
|
+
'percent_checked',
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
const valid = $derived(
|
|
138
|
+
name.trim().length > 0 &&
|
|
139
|
+
formulaError === null &&
|
|
140
|
+
(!needsOptions || options.every((o) => o.label.trim().length > 0)),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `disabled={busy}` reaches the button on the next render, and two quick clicks are one render
|
|
145
|
+
* apart — so the flag is set in the same tick as the click and read before anything is sent.
|
|
146
|
+
*/
|
|
147
|
+
function submit() {
|
|
148
|
+
if (submitting || !valid) return
|
|
149
|
+
submitting = true
|
|
150
|
+
onSubmit({ name: name.trim(), type, config: $state.snapshot(config) })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
$effect(() => {
|
|
154
|
+
if (!busy) submitting = false
|
|
155
|
+
})
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<Dialog
|
|
159
|
+
{open}
|
|
160
|
+
title={property ? t('db_edit_property') : t('db_add_property')}
|
|
161
|
+
size="md"
|
|
162
|
+
onOpenChange={(o) => !o && onClose()}
|
|
163
|
+
>
|
|
164
|
+
<div class="form">
|
|
165
|
+
<Field label={t('db_property_name')}>
|
|
166
|
+
{#snippet children(id: string)}
|
|
167
|
+
<Input {id} bind:value={name} placeholder={t('db_property_name')} />
|
|
168
|
+
{/snippet}
|
|
169
|
+
</Field>
|
|
170
|
+
|
|
171
|
+
<Field label={t('db_property_type')} hint={property ? t('db_property_retype_hint') : undefined}>
|
|
172
|
+
{#snippet children(id: string)}
|
|
173
|
+
<Select
|
|
174
|
+
{id}
|
|
175
|
+
value={type}
|
|
176
|
+
options={CREATABLE_TYPES.map((option) => ({
|
|
177
|
+
value: option,
|
|
178
|
+
label: t(`db_type_${option}`),
|
|
179
|
+
icon: descriptorFor(option).icon,
|
|
180
|
+
}))}
|
|
181
|
+
onValueChange={(next) => (type = next as PropertyType)}
|
|
182
|
+
/>
|
|
183
|
+
{/snippet}
|
|
184
|
+
</Field>
|
|
185
|
+
|
|
186
|
+
{#if needsOptions}
|
|
187
|
+
<fieldset class="group">
|
|
188
|
+
<legend>{t('db_options')}</legend>
|
|
189
|
+
{#if options.length === 0}
|
|
190
|
+
<p class="hint">{t('db_options_none')}</p>
|
|
191
|
+
{/if}
|
|
192
|
+
<ul class="options">
|
|
193
|
+
{#each options as option (option.id)}
|
|
194
|
+
<li>
|
|
195
|
+
<span class="swatch" style:background={toneFor(option.colour).bg}></span>
|
|
196
|
+
<input
|
|
197
|
+
class="opt-label"
|
|
198
|
+
value={option.label}
|
|
199
|
+
aria-label={t('db_option_label')}
|
|
200
|
+
oninput={(e) => patchOption(option.id, { label: e.currentTarget.value })}
|
|
201
|
+
/>
|
|
202
|
+
<select
|
|
203
|
+
class="opt-select"
|
|
204
|
+
value={option.colour}
|
|
205
|
+
aria-label={t('db_option_colour')}
|
|
206
|
+
onchange={(e) => patchOption(option.id, { colour: e.currentTarget.value })}
|
|
207
|
+
>
|
|
208
|
+
{#each OPTION_COLOURS as colour (colour)}
|
|
209
|
+
<option value={colour}>{t(`db_colour_${colour}`)}</option>
|
|
210
|
+
{/each}
|
|
211
|
+
</select>
|
|
212
|
+
{#if type === 'status'}
|
|
213
|
+
<select
|
|
214
|
+
class="opt-select"
|
|
215
|
+
value={option.group ?? 'todo'}
|
|
216
|
+
aria-label={t('db_option_group')}
|
|
217
|
+
onchange={(e) =>
|
|
218
|
+
patchOption(option.id, {
|
|
219
|
+
group: e.currentTarget.value as 'todo' | 'doing' | 'done',
|
|
220
|
+
})}
|
|
221
|
+
>
|
|
222
|
+
{#each STATUS_GROUPS as band (band)}
|
|
223
|
+
<option value={band}>{t(`db_status_${band}`)}</option>
|
|
224
|
+
{/each}
|
|
225
|
+
</select>
|
|
226
|
+
{/if}
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
class="opt-remove"
|
|
230
|
+
aria-label={t('db_option_remove', { label: option.label })}
|
|
231
|
+
onclick={() => removeOption(option.id)}
|
|
232
|
+
>
|
|
233
|
+
<Icon name="x" size={13} strokeWidth={1.9} />
|
|
234
|
+
</button>
|
|
235
|
+
</li>
|
|
236
|
+
{/each}
|
|
237
|
+
</ul>
|
|
238
|
+
<Button size="sm" variant="secondary" onclick={addOption}>{t('db_option_add')}</Button>
|
|
239
|
+
</fieldset>
|
|
240
|
+
{/if}
|
|
241
|
+
|
|
242
|
+
{#if type === 'number'}
|
|
243
|
+
<Field label={t('db_number_format')}>
|
|
244
|
+
{#snippet children(id: string)}
|
|
245
|
+
<Select
|
|
246
|
+
{id}
|
|
247
|
+
value={config.format ?? 'plain'}
|
|
248
|
+
options={['plain', 'percent', 'currency'].map((f) => ({ value: f, label: t(`db_format_${f}`) }))}
|
|
249
|
+
onValueChange={(next) => setConfig({ format: next })}
|
|
250
|
+
/>
|
|
251
|
+
{/snippet}
|
|
252
|
+
</Field>
|
|
253
|
+
<Field label={t('db_precision')}>
|
|
254
|
+
{#snippet children(id: string)}
|
|
255
|
+
<Input
|
|
256
|
+
{id}
|
|
257
|
+
type="number"
|
|
258
|
+
min="0"
|
|
259
|
+
max="8"
|
|
260
|
+
value={config.precision ?? ''}
|
|
261
|
+
oninput={(e) => {
|
|
262
|
+
const raw = (e.currentTarget as HTMLInputElement).value
|
|
263
|
+
setConfig({ precision: raw === '' ? undefined : Number(raw) })
|
|
264
|
+
}}
|
|
265
|
+
/>
|
|
266
|
+
{/snippet}
|
|
267
|
+
</Field>
|
|
268
|
+
{/if}
|
|
269
|
+
|
|
270
|
+
{#if type === 'date'}
|
|
271
|
+
<Switch
|
|
272
|
+
checked={config.includeTime === true}
|
|
273
|
+
label={t('db_include_time')}
|
|
274
|
+
onCheckedChange={(on) => setConfig({ includeTime: on })}
|
|
275
|
+
/>
|
|
276
|
+
{/if}
|
|
277
|
+
|
|
278
|
+
{#if type === 'person'}
|
|
279
|
+
<Switch
|
|
280
|
+
checked={config.multiple !== false}
|
|
281
|
+
label={t('db_person_multiple')}
|
|
282
|
+
onCheckedChange={(on) => setConfig({ multiple: on })}
|
|
283
|
+
/>
|
|
284
|
+
{/if}
|
|
285
|
+
|
|
286
|
+
{#if type === 'relation'}
|
|
287
|
+
<Field label={t('db_relation_database')} hint={t('db_relation_database_hint')}>
|
|
288
|
+
{#snippet children(id: string)}
|
|
289
|
+
<Select
|
|
290
|
+
{id}
|
|
291
|
+
value={config.relationDatabaseId ?? ''}
|
|
292
|
+
placeholder={t('db_relation_choose')}
|
|
293
|
+
options={targets
|
|
294
|
+
.filter((d) => d.id !== databaseId)
|
|
295
|
+
.map((d) => ({ value: d.id, label: d.name || t('untitled') }))}
|
|
296
|
+
onValueChange={(next) => setConfig({ relationDatabaseId: next || undefined })}
|
|
297
|
+
/>
|
|
298
|
+
{/snippet}
|
|
299
|
+
</Field>
|
|
300
|
+
{/if}
|
|
301
|
+
|
|
302
|
+
{#if type === 'rollup'}
|
|
303
|
+
<Field label={t('db_rollup_relation')}>
|
|
304
|
+
{#snippet children(id: string)}
|
|
305
|
+
<Select
|
|
306
|
+
{id}
|
|
307
|
+
value={config.rollupRelationPropertyId ?? ''}
|
|
308
|
+
placeholder={t('db_rollup_choose')}
|
|
309
|
+
options={relations.map((r) => ({ value: r.id, label: r.name }))}
|
|
310
|
+
onValueChange={(next) => setConfig({ rollupRelationPropertyId: next || undefined })}
|
|
311
|
+
/>
|
|
312
|
+
{/snippet}
|
|
313
|
+
</Field>
|
|
314
|
+
{#if relations.length === 0}
|
|
315
|
+
<p class="hint">{t('db_rollup_needs_relation')}</p>
|
|
316
|
+
{/if}
|
|
317
|
+
<Field label={t('db_rollup_target')}>
|
|
318
|
+
{#snippet children(id: string)}
|
|
319
|
+
<Select
|
|
320
|
+
{id}
|
|
321
|
+
value={config.rollupTargetPropertyId ?? ''}
|
|
322
|
+
placeholder={t('db_rollup_choose')}
|
|
323
|
+
disabled={!viaDatabaseId}
|
|
324
|
+
options={(viaDatabase.data?.properties ?? []).map((p) => ({ value: p.id, label: p.name }))}
|
|
325
|
+
onValueChange={(next) => setConfig({ rollupTargetPropertyId: next || undefined })}
|
|
326
|
+
/>
|
|
327
|
+
{/snippet}
|
|
328
|
+
</Field>
|
|
329
|
+
<Field label={t('db_rollup_function')}>
|
|
330
|
+
{#snippet children(id: string)}
|
|
331
|
+
<Select
|
|
332
|
+
{id}
|
|
333
|
+
value={config.rollupFunction ?? 'count'}
|
|
334
|
+
options={ROLLUP_FUNCTIONS.map((fn) => ({ value: fn, label: t(`db_rollup_${fn}`) }))}
|
|
335
|
+
onValueChange={(next) => setConfig({ rollupFunction: next as RollupFunction })}
|
|
336
|
+
/>
|
|
337
|
+
{/snippet}
|
|
338
|
+
</Field>
|
|
339
|
+
{/if}
|
|
340
|
+
|
|
341
|
+
{#if type === 'formula'}
|
|
342
|
+
<Field
|
|
343
|
+
label={t('db_expression')}
|
|
344
|
+
hint={formulaError ? undefined : t('db_expression_hint')}
|
|
345
|
+
error={formulaError}
|
|
346
|
+
>
|
|
347
|
+
{#snippet children(id: string)}
|
|
348
|
+
<Input
|
|
349
|
+
{id}
|
|
350
|
+
mono
|
|
351
|
+
value={config.expression ?? ''}
|
|
352
|
+
placeholder={'round(prop("Hours") / 8, 2)'}
|
|
353
|
+
oninput={(e) => setConfig({ expression: (e.currentTarget as HTMLInputElement).value })}
|
|
354
|
+
/>
|
|
355
|
+
{/snippet}
|
|
356
|
+
</Field>
|
|
357
|
+
{/if}
|
|
358
|
+
|
|
359
|
+
{#if descriptor.readOnly && type !== 'formula' && type !== 'rollup'}
|
|
360
|
+
<p class="hint">{t('db_type_readonly_hint')}</p>
|
|
361
|
+
{/if}
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
{#snippet footer()}
|
|
365
|
+
<Button variant="secondary" onclick={onClose}>{t('common.cancel')}</Button>
|
|
366
|
+
<Button disabled={!valid || busy || submitting} onclick={submit}>
|
|
367
|
+
{property ? t('common.save') : t('common.add')}
|
|
368
|
+
</Button>
|
|
369
|
+
{/snippet}
|
|
370
|
+
</Dialog>
|
|
371
|
+
|
|
372
|
+
<style>
|
|
373
|
+
.form {
|
|
374
|
+
display: flex;
|
|
375
|
+
flex-direction: column;
|
|
376
|
+
gap: 16px;
|
|
377
|
+
}
|
|
378
|
+
.group {
|
|
379
|
+
border: 1px solid var(--kern-border);
|
|
380
|
+
border-radius: var(--kern-r-lg);
|
|
381
|
+
padding: 12px 14px 14px;
|
|
382
|
+
margin: 0;
|
|
383
|
+
display: flex;
|
|
384
|
+
flex-direction: column;
|
|
385
|
+
gap: 10px;
|
|
386
|
+
}
|
|
387
|
+
legend {
|
|
388
|
+
padding-inline: 4px;
|
|
389
|
+
font-size: 12px;
|
|
390
|
+
font-weight: 600;
|
|
391
|
+
color: var(--kern-ink-550);
|
|
392
|
+
}
|
|
393
|
+
.options {
|
|
394
|
+
list-style: none;
|
|
395
|
+
margin: 0;
|
|
396
|
+
padding: 0;
|
|
397
|
+
display: flex;
|
|
398
|
+
flex-direction: column;
|
|
399
|
+
gap: 6px;
|
|
400
|
+
}
|
|
401
|
+
.options li {
|
|
402
|
+
display: flex;
|
|
403
|
+
align-items: center;
|
|
404
|
+
gap: 7px;
|
|
405
|
+
}
|
|
406
|
+
.swatch {
|
|
407
|
+
flex: none;
|
|
408
|
+
width: 12px;
|
|
409
|
+
height: 12px;
|
|
410
|
+
border-radius: 3px;
|
|
411
|
+
}
|
|
412
|
+
.opt-label {
|
|
413
|
+
flex: 1;
|
|
414
|
+
min-width: 0;
|
|
415
|
+
height: 30px;
|
|
416
|
+
padding: 0 9px;
|
|
417
|
+
border: 1px solid var(--kern-border);
|
|
418
|
+
border-radius: var(--kern-r-md);
|
|
419
|
+
background: var(--kern-surface-raised);
|
|
420
|
+
color: var(--kern-ink-700);
|
|
421
|
+
font: inherit;
|
|
422
|
+
font-size: 13px;
|
|
423
|
+
}
|
|
424
|
+
.opt-label:focus {
|
|
425
|
+
outline: none;
|
|
426
|
+
border-color: var(--kern-accent);
|
|
427
|
+
box-shadow: 0 0 0 3px var(--kern-ring);
|
|
428
|
+
}
|
|
429
|
+
.opt-select {
|
|
430
|
+
flex: none;
|
|
431
|
+
height: 30px;
|
|
432
|
+
padding: 0 6px;
|
|
433
|
+
border: 1px solid var(--kern-border);
|
|
434
|
+
border-radius: var(--kern-r-md);
|
|
435
|
+
background: var(--kern-surface-raised);
|
|
436
|
+
color: var(--kern-ink-700);
|
|
437
|
+
font: inherit;
|
|
438
|
+
font-size: 12.5px;
|
|
439
|
+
}
|
|
440
|
+
.opt-remove {
|
|
441
|
+
flex: none;
|
|
442
|
+
display: inline-grid;
|
|
443
|
+
place-items: center;
|
|
444
|
+
width: 28px;
|
|
445
|
+
height: 28px;
|
|
446
|
+
border: 0;
|
|
447
|
+
border-radius: var(--kern-r-sm);
|
|
448
|
+
background: none;
|
|
449
|
+
color: var(--kern-ink-450);
|
|
450
|
+
}
|
|
451
|
+
.opt-remove:hover {
|
|
452
|
+
background: var(--kern-surface-hover);
|
|
453
|
+
color: var(--kern-ink-900);
|
|
454
|
+
}
|
|
455
|
+
.hint {
|
|
456
|
+
margin: 0;
|
|
457
|
+
font-size: 12.5px;
|
|
458
|
+
color: var(--kern-ink-450);
|
|
459
|
+
}
|
|
460
|
+
</style>
|