@kernhq/module-tracker 0.10.0 → 0.11.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/package.json +16 -3
- package/src/client/api-instance.ts +34 -0
- package/src/client/components/BoardCard.svelte +182 -0
- package/src/client/components/BoardView.svelte +248 -0
- package/src/client/components/CommentComposer.svelte +219 -0
- package/src/client/components/CommentThread.svelte +389 -0
- package/src/client/components/CustomField.svelte +333 -0
- package/src/client/components/DueDate.svelte +44 -0
- package/src/client/components/FilterMenu.svelte +91 -0
- package/src/client/components/GroupHeader.svelte +137 -0
- package/src/client/components/HomeLinks.svelte +38 -0
- package/src/client/components/IssueApprovals.svelte +217 -0
- package/src/client/components/IssueConnections.svelte +424 -0
- package/src/client/components/IssueDetailPanel.svelte +1337 -0
- package/src/client/components/IssueInline.svelte +75 -0
- package/src/client/components/IssueListView.svelte +153 -0
- package/src/client/components/IssuePicker.svelte +180 -0
- package/src/client/components/IssueRow.svelte +171 -0
- package/src/client/components/IssueTime.svelte +306 -0
- package/src/client/components/KqlInput.svelte +295 -0
- package/src/client/components/NewIssueDialog.svelte +210 -0
- package/src/client/components/NewProjectDialog.svelte +235 -0
- package/src/client/components/PriorityGlyph.svelte +52 -0
- package/src/client/components/SaveViewDialog.svelte +153 -0
- package/src/client/components/SidebarProjects.svelte +263 -0
- package/src/client/components/SidebarViews.svelte +336 -0
- package/src/client/components/StatusIcon.svelte +50 -0
- package/src/client/components/TrackerControls.svelte +109 -0
- package/src/client/components/TrackerSidebar.svelte +96 -0
- package/src/client/context.svelte.ts +105 -0
- package/src/client/core-api.ts +35 -0
- package/src/client/csv.test.ts +50 -0
- package/src/client/csv.ts +60 -0
- package/src/client/filters.ts +94 -0
- package/src/client/i18n.ts +3260 -0
- package/src/client/index.ts +12 -0
- package/src/client/labels.ts +200 -0
- package/src/client/mock.ts +2729 -0
- package/src/client/module.ts +491 -0
- package/src/client/nav.test.ts +59 -0
- package/src/client/nav.ts +84 -0
- package/src/client/pages/IntakePage.svelte +287 -0
- package/src/client/pages/IssuesPage.svelte +701 -0
- package/src/client/pages/ProjectPage.svelte +29 -0
- package/src/client/pages/ReportsPage.svelte +365 -0
- package/src/client/permissions.ts +33 -0
- package/src/client/planning/PlanningSections.svelte +259 -0
- package/src/client/project/ComponentsPage.svelte +423 -0
- package/src/client/project/CyclesPage.svelte +488 -0
- package/src/client/project/MilestonesPage.svelte +342 -0
- package/src/client/project/PlanCard.svelte +191 -0
- package/src/client/project/ProjectShell.svelte +93 -0
- package/src/client/project/TemplatesPage.svelte +321 -0
- package/src/client/project/context.svelte.ts +58 -0
- package/src/client/query.ts +50 -0
- package/src/client/rank.test.ts +78 -0
- package/src/client/rank.ts +13 -4
- package/src/client/recurrence.test.ts +37 -0
- package/src/client/recurrence.ts +89 -0
- package/src/client/richtext.ts +155 -0
- package/src/client/settings/CycleList.svelte +422 -0
- package/src/client/settings/FieldEditor.svelte +324 -0
- package/src/client/settings/FieldsSettings.svelte +273 -0
- package/src/client/settings/ImportSettings.svelte +410 -0
- package/src/client/settings/LayoutEditor.svelte +263 -0
- package/src/client/settings/PlanningList.svelte +249 -0
- package/src/client/settings/PlanningSettings.svelte +179 -0
- package/src/client/settings/ProjectsSettings.svelte +462 -0
- package/src/client/settings/RepeatingSettings.svelte +293 -0
- package/src/client/settings/TypeEditor.svelte +214 -0
- package/src/client/settings/TypesSettings.svelte +384 -0
- package/src/client/settings/WorkflowsSettings.svelte +645 -0
- package/src/client/settings/mutations.ts +19 -0
- package/src/client/time.test.ts +39 -0
- package/src/client/time.ts +34 -0
- package/src/client/tracker.test.ts +399 -0
- package/src/client/views.ts +27 -0
- package/src/client/widgets/AssignedCountWidget.svelte +14 -0
- package/src/client/widgets/CountWidget.svelte +56 -0
- package/src/client/widgets/CycleWidget.svelte +85 -0
- package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
- package/src/client/widgets/IssuesWidget.svelte +222 -0
- package/src/client/widgets/ThroughputWidget.svelte +68 -0
- package/src/client/widgets/TimerWidget.svelte +116 -0
- package/src/client/widgets/VelocityWidget.svelte +55 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Checkbox, Dialog, IconButton, Input, Select, Textarea } from '@kernhq/ui'
|
|
3
|
+
import { t } from '../i18n.js'
|
|
4
|
+
import type { FieldDef, FieldOption, FieldType } from '../index.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creating or editing one custom field.
|
|
8
|
+
*
|
|
9
|
+
* A field's `key` and `type` are fixed after creation: the key is where values live in
|
|
10
|
+
* `issues.custom` and the type is what validates them, so changing either would reinterpret every
|
|
11
|
+
* value already stored. The dialog says so rather than silently disabling the inputs.
|
|
12
|
+
*/
|
|
13
|
+
interface Props {
|
|
14
|
+
open: boolean
|
|
15
|
+
/** null when creating */
|
|
16
|
+
field: FieldDef | null
|
|
17
|
+
busy?: boolean
|
|
18
|
+
onclose: () => void
|
|
19
|
+
onsave: (input: Record<string, unknown>) => void
|
|
20
|
+
}
|
|
21
|
+
let { open, field, busy = false, onclose, onsave }: Props = $props()
|
|
22
|
+
|
|
23
|
+
const TYPES: FieldType[] = [
|
|
24
|
+
'text',
|
|
25
|
+
'textarea',
|
|
26
|
+
'number',
|
|
27
|
+
'select',
|
|
28
|
+
'multiselect',
|
|
29
|
+
'date',
|
|
30
|
+
'datetime',
|
|
31
|
+
'user',
|
|
32
|
+
'multiuser',
|
|
33
|
+
'label',
|
|
34
|
+
'url',
|
|
35
|
+
'checkbox',
|
|
36
|
+
'relation',
|
|
37
|
+
'formula',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const TYPE_LABELS: Record<FieldType, () => string> = {
|
|
41
|
+
text: () => t('ft_text'),
|
|
42
|
+
textarea: () => t('ft_textarea'),
|
|
43
|
+
number: () => t('ft_number'),
|
|
44
|
+
select: () => t('ft_select'),
|
|
45
|
+
multiselect: () => t('ft_multiselect'),
|
|
46
|
+
date: () => t('ft_date'),
|
|
47
|
+
datetime: () => t('ft_datetime'),
|
|
48
|
+
user: () => t('ft_user'),
|
|
49
|
+
multiuser: () => t('ft_multiuser'),
|
|
50
|
+
label: () => t('ft_label'),
|
|
51
|
+
url: () => t('ft_url'),
|
|
52
|
+
checkbox: () => t('ft_checkbox'),
|
|
53
|
+
relation: () => t('ft_relation'),
|
|
54
|
+
formula: () => t('ft_formula'),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const HAS_OPTIONS: ReadonlySet<FieldType> = new Set(['select', 'multiselect', 'label'])
|
|
58
|
+
|
|
59
|
+
let name = $state('')
|
|
60
|
+
let key = $state('')
|
|
61
|
+
let description = $state('')
|
|
62
|
+
let type = $state<FieldType>('text')
|
|
63
|
+
let required = $state(false)
|
|
64
|
+
let searchable = $state(false)
|
|
65
|
+
let showInCards = $state(false)
|
|
66
|
+
let options = $state<FieldOption[]>([])
|
|
67
|
+
let minText = $state('')
|
|
68
|
+
let maxText = $state('')
|
|
69
|
+
let maxLengthText = $state('')
|
|
70
|
+
let pattern = $state('')
|
|
71
|
+
/** Touched once, the key stops following the name — otherwise a rename would rewrite it. */
|
|
72
|
+
let keyEdited = $state(false)
|
|
73
|
+
|
|
74
|
+
const editing = $derived(Boolean(field))
|
|
75
|
+
|
|
76
|
+
/** Reset every time the dialog opens, so a cancelled edit does not leak into the next one. */
|
|
77
|
+
$effect(() => {
|
|
78
|
+
if (!open) return
|
|
79
|
+
name = field?.name ?? ''
|
|
80
|
+
key = field?.key ?? ''
|
|
81
|
+
description = field?.description ?? ''
|
|
82
|
+
type = field?.type ?? 'text'
|
|
83
|
+
required = field?.required ?? false
|
|
84
|
+
searchable = field?.searchable ?? false
|
|
85
|
+
showInCards = field?.showInCards ?? false
|
|
86
|
+
options = field ? field.options.map((o) => ({ ...o })) : []
|
|
87
|
+
minText = field?.config.min?.toString() ?? ''
|
|
88
|
+
maxText = field?.config.max?.toString() ?? ''
|
|
89
|
+
maxLengthText = field?.config.maxLength?.toString() ?? ''
|
|
90
|
+
pattern = field?.config.pattern ?? ''
|
|
91
|
+
keyEdited = Boolean(field)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
/** `Customer email` → `customer_email`: the key is a machine name, not a sentence. */
|
|
95
|
+
const slugify = (value: string) =>
|
|
96
|
+
value
|
|
97
|
+
.toLowerCase()
|
|
98
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
99
|
+
.replace(/^_+|_+$/g, '')
|
|
100
|
+
.slice(0, 40)
|
|
101
|
+
|
|
102
|
+
$effect(() => {
|
|
103
|
+
if (!keyEdited) key = slugify(name)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const keyValid = $derived(/^[a-z][a-z0-9_]*$/.test(key))
|
|
107
|
+
/**
|
|
108
|
+
* A bad pattern is compiled here rather than at value-check time: `config.pattern` is
|
|
109
|
+
* administrator-supplied, and one unparseable save would otherwise poison every write to a project.
|
|
110
|
+
*/
|
|
111
|
+
const patternValid = $derived.by(() => {
|
|
112
|
+
if (!pattern) return true
|
|
113
|
+
try {
|
|
114
|
+
new RegExp(pattern)
|
|
115
|
+
return true
|
|
116
|
+
} catch {
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
const optionsValid = $derived(
|
|
121
|
+
!HAS_OPTIONS.has(type) || (options.length > 0 && options.every((o) => o.label.trim())),
|
|
122
|
+
)
|
|
123
|
+
const valid = $derived(Boolean(name.trim()) && keyValid && patternValid && optionsValid)
|
|
124
|
+
|
|
125
|
+
function addOption() {
|
|
126
|
+
options = [
|
|
127
|
+
...options,
|
|
128
|
+
{
|
|
129
|
+
id: `opt_${Math.random().toString(36).slice(2, 8)}`,
|
|
130
|
+
label: '',
|
|
131
|
+
color: null,
|
|
132
|
+
order: options.length,
|
|
133
|
+
archived: false,
|
|
134
|
+
},
|
|
135
|
+
]
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function save() {
|
|
139
|
+
if (!valid || busy) return
|
|
140
|
+
const config: Record<string, unknown> = {}
|
|
141
|
+
if (type === 'number' && minText.trim()) config.min = Number(minText)
|
|
142
|
+
if (type === 'number' && maxText.trim()) config.max = Number(maxText)
|
|
143
|
+
if ((type === 'text' || type === 'textarea') && maxLengthText.trim())
|
|
144
|
+
config.maxLength = Number(maxLengthText)
|
|
145
|
+
if ((type === 'text' || type === 'textarea') && pattern.trim()) config.pattern = pattern.trim()
|
|
146
|
+
|
|
147
|
+
onsave({
|
|
148
|
+
name: name.trim(),
|
|
149
|
+
description: description.trim() || null,
|
|
150
|
+
required,
|
|
151
|
+
searchable,
|
|
152
|
+
showInCards,
|
|
153
|
+
config,
|
|
154
|
+
...(HAS_OPTIONS.has(type) ? { options: options.map((o, i) => ({ ...o, order: i })) } : {}),
|
|
155
|
+
// key and type are fixed once values exist under them
|
|
156
|
+
...(editing ? {} : { key, type }),
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
</script>
|
|
160
|
+
|
|
161
|
+
<Dialog
|
|
162
|
+
{open}
|
|
163
|
+
title={editing ? t('field_edit') : t('field_new')}
|
|
164
|
+
size="md"
|
|
165
|
+
onOpenChange={(next: boolean) => {
|
|
166
|
+
if (!next) onclose()
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<div class="grid gap-3">
|
|
170
|
+
<label class="grid gap-1">
|
|
171
|
+
<span class="lbl">{t('field_name')}</span>
|
|
172
|
+
<Input bind:value={name} placeholder="Severity" data-testid="field-name" />
|
|
173
|
+
</label>
|
|
174
|
+
|
|
175
|
+
<label class="grid gap-1">
|
|
176
|
+
<span class="lbl">{t('field_key')}</span>
|
|
177
|
+
<Input
|
|
178
|
+
bind:value={key}
|
|
179
|
+
disabled={editing}
|
|
180
|
+
oninput={() => (keyEdited = true)}
|
|
181
|
+
placeholder="severity"
|
|
182
|
+
data-testid="field-key"
|
|
183
|
+
/>
|
|
184
|
+
<span class="hint" class:bad={!keyValid && key.length > 0}>
|
|
185
|
+
{editing ? t('field_key_fixed') : t('field_key_hint')}
|
|
186
|
+
</span>
|
|
187
|
+
</label>
|
|
188
|
+
|
|
189
|
+
<label class="grid gap-1">
|
|
190
|
+
<span class="lbl">{t('field_type')}</span>
|
|
191
|
+
<Select
|
|
192
|
+
value={type}
|
|
193
|
+
disabled={editing}
|
|
194
|
+
options={TYPES.map((kind) => ({ value: kind, label: TYPE_LABELS[kind]() }))}
|
|
195
|
+
onValueChange={(v: string) => (type = v as FieldType)}
|
|
196
|
+
/>
|
|
197
|
+
{#if editing}
|
|
198
|
+
<span class="hint">{t('field_type_fixed')}</span>
|
|
199
|
+
{/if}
|
|
200
|
+
</label>
|
|
201
|
+
|
|
202
|
+
<label class="grid gap-1">
|
|
203
|
+
<span class="lbl">{t('field_description')}</span>
|
|
204
|
+
<Textarea bind:value={description} rows={2} />
|
|
205
|
+
</label>
|
|
206
|
+
|
|
207
|
+
{#if HAS_OPTIONS.has(type)}
|
|
208
|
+
<div class="grid gap-1">
|
|
209
|
+
<span class="lbl">{t('field_options')}</span>
|
|
210
|
+
{#each options as option, i (option.id)}
|
|
211
|
+
<div class="orow">
|
|
212
|
+
<Input
|
|
213
|
+
value={option.label}
|
|
214
|
+
placeholder={t('field_option_label')}
|
|
215
|
+
oninput={(e: Event) => {
|
|
216
|
+
const value = (e.currentTarget as HTMLInputElement).value
|
|
217
|
+
options = options.map((o, j) => (i === j ? { ...o, label: value } : o))
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
220
|
+
<IconButton
|
|
221
|
+
icon="x"
|
|
222
|
+
size={26}
|
|
223
|
+
label={t('field_option_remove')}
|
|
224
|
+
onclick={() => (options = options.filter((_, j) => j !== i))}
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
{/each}
|
|
228
|
+
<div>
|
|
229
|
+
<Button size="sm" variant="ghost" onclick={addOption}>{t('field_option_add')}</Button>
|
|
230
|
+
</div>
|
|
231
|
+
{#if !optionsValid}
|
|
232
|
+
<span class="hint bad">{t('field_options_required')}</span>
|
|
233
|
+
{/if}
|
|
234
|
+
</div>
|
|
235
|
+
{/if}
|
|
236
|
+
|
|
237
|
+
{#if type === 'number'}
|
|
238
|
+
<div class="two">
|
|
239
|
+
<label class="grid gap-1">
|
|
240
|
+
<span class="lbl">{t('field_min')}</span>
|
|
241
|
+
<Input bind:value={minText} type="number" />
|
|
242
|
+
</label>
|
|
243
|
+
<label class="grid gap-1">
|
|
244
|
+
<span class="lbl">{t('field_max')}</span>
|
|
245
|
+
<Input bind:value={maxText} type="number" />
|
|
246
|
+
</label>
|
|
247
|
+
</div>
|
|
248
|
+
{/if}
|
|
249
|
+
|
|
250
|
+
{#if type === 'text' || type === 'textarea'}
|
|
251
|
+
<div class="two">
|
|
252
|
+
<label class="grid gap-1">
|
|
253
|
+
<span class="lbl">{t('field_maxlength')}</span>
|
|
254
|
+
<Input bind:value={maxLengthText} type="number" />
|
|
255
|
+
</label>
|
|
256
|
+
<label class="grid gap-1">
|
|
257
|
+
<span class="lbl">{t('field_pattern')}</span>
|
|
258
|
+
<Input bind:value={pattern} placeholder="^[A-Z]{2}-\d+$" />
|
|
259
|
+
{#if !patternValid}
|
|
260
|
+
<span class="hint bad">{t('field_pattern_invalid')}</span>
|
|
261
|
+
{/if}
|
|
262
|
+
</label>
|
|
263
|
+
</div>
|
|
264
|
+
{/if}
|
|
265
|
+
|
|
266
|
+
<label class="check">
|
|
267
|
+
<Checkbox checked={required} onCheckedChange={(v: boolean) => (required = v)} />
|
|
268
|
+
<span>
|
|
269
|
+
{t('field_required')}
|
|
270
|
+
<span class="hint">{t('field_required_hint')}</span>
|
|
271
|
+
</span>
|
|
272
|
+
</label>
|
|
273
|
+
<label class="check">
|
|
274
|
+
<Checkbox checked={searchable} onCheckedChange={(v: boolean) => (searchable = v)} />
|
|
275
|
+
<span>
|
|
276
|
+
{t('field_searchable')}
|
|
277
|
+
<span class="hint">{t('field_searchable_hint')}</span>
|
|
278
|
+
</span>
|
|
279
|
+
</label>
|
|
280
|
+
<label class="check">
|
|
281
|
+
<Checkbox checked={showInCards} onCheckedChange={(v: boolean) => (showInCards = v)} />
|
|
282
|
+
<span>{t('field_show_in_cards')}</span>
|
|
283
|
+
</label>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
{#snippet footer()}
|
|
287
|
+
<Button variant="ghost" size="sm" onclick={onclose}>{t('common.cancel')}</Button>
|
|
288
|
+
<Button size="sm" disabled={!valid} loading={busy} onclick={save} data-testid="field-save">
|
|
289
|
+
{editing ? t('common.save') : t('common.add')}
|
|
290
|
+
</Button>
|
|
291
|
+
{/snippet}
|
|
292
|
+
</Dialog>
|
|
293
|
+
|
|
294
|
+
<style>
|
|
295
|
+
.lbl {
|
|
296
|
+
font-size: 12px;
|
|
297
|
+
color: var(--kern-ink-550);
|
|
298
|
+
}
|
|
299
|
+
.hint {
|
|
300
|
+
display: block;
|
|
301
|
+
font-size: 11.5px;
|
|
302
|
+
color: var(--kern-ink-400);
|
|
303
|
+
}
|
|
304
|
+
.hint.bad {
|
|
305
|
+
color: var(--kern-danger);
|
|
306
|
+
}
|
|
307
|
+
.two {
|
|
308
|
+
display: grid;
|
|
309
|
+
grid-template-columns: 1fr 1fr;
|
|
310
|
+
gap: 10px;
|
|
311
|
+
}
|
|
312
|
+
.orow {
|
|
313
|
+
display: flex;
|
|
314
|
+
align-items: center;
|
|
315
|
+
gap: 6px;
|
|
316
|
+
}
|
|
317
|
+
.check {
|
|
318
|
+
display: flex;
|
|
319
|
+
align-items: flex-start;
|
|
320
|
+
gap: 8px;
|
|
321
|
+
font-size: 13px;
|
|
322
|
+
cursor: pointer;
|
|
323
|
+
}
|
|
324
|
+
</style>
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
Checkbox,
|
|
6
|
+
EmptyState,
|
|
7
|
+
navigation,
|
|
8
|
+
SettingsPage,
|
|
9
|
+
SettingsSection,
|
|
10
|
+
Spinner,
|
|
11
|
+
session,
|
|
12
|
+
toast,
|
|
13
|
+
} from '@kernhq/ui'
|
|
14
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
15
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
16
|
+
import { t } from '../i18n.js'
|
|
17
|
+
import type { FieldDef } from '../index.js'
|
|
18
|
+
import { canTracker } from '../permissions.js'
|
|
19
|
+
import { trackerKeys } from '../query.js'
|
|
20
|
+
import FieldEditor from './FieldEditor.svelte'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The workspace's custom fields.
|
|
24
|
+
*
|
|
25
|
+
* Creating a field here is what makes the layout editor useful: a field exists once, in one
|
|
26
|
+
* catalogue, and each work item type decides where — or whether — it appears.
|
|
27
|
+
*/
|
|
28
|
+
const api = getTrackerApi()
|
|
29
|
+
const queryClient = useQueryClient()
|
|
30
|
+
|
|
31
|
+
const slug = $derived(navigation.workspaceSlug)
|
|
32
|
+
const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
|
|
33
|
+
const canManage = $derived(canTracker('fieldManage'))
|
|
34
|
+
|
|
35
|
+
let editing = $state<FieldDef | null>(null)
|
|
36
|
+
let editorOpen = $state(false)
|
|
37
|
+
let confirmingDelete = $state<FieldDef | null>(null)
|
|
38
|
+
let showArchived = $state(false)
|
|
39
|
+
|
|
40
|
+
const fieldsQuery = createQuery(() => ({
|
|
41
|
+
// The toggle is part of the key: without it the cached list is served and the switch does
|
|
42
|
+
// nothing until something else happens to invalidate it.
|
|
43
|
+
queryKey: [...trackerKeys.fields(workspaceId), showArchived],
|
|
44
|
+
queryFn: () => api.fields.list({ workspaceId, includeArchived: showArchived }),
|
|
45
|
+
enabled: Boolean(workspaceId),
|
|
46
|
+
}))
|
|
47
|
+
const fields = $derived(fieldsQuery.data ?? [])
|
|
48
|
+
|
|
49
|
+
const invalidate = () => {
|
|
50
|
+
void queryClient.invalidateQueries({ queryKey: trackerKeys.fields(workspaceId) })
|
|
51
|
+
// A field's existence changes every type's resolved layout, so those go too.
|
|
52
|
+
void queryClient.invalidateQueries({ queryKey: ['tracker', 'type', workspaceId] })
|
|
53
|
+
}
|
|
54
|
+
const fail = (error: Error) => toast.error(error.message)
|
|
55
|
+
|
|
56
|
+
const save = createMutation(() => ({
|
|
57
|
+
mutationFn: (input: Record<string, unknown>) =>
|
|
58
|
+
editing
|
|
59
|
+
? api.fields.update({ workspaceId, id: editing.id, patch: input })
|
|
60
|
+
: api.fields.create({ workspaceId, ...(input as { key: string; name: string; type: never }) }),
|
|
61
|
+
onSuccess: () => {
|
|
62
|
+
editorOpen = false
|
|
63
|
+
editing = null
|
|
64
|
+
invalidate()
|
|
65
|
+
},
|
|
66
|
+
onError: fail,
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
const remove = createMutation(() => ({
|
|
70
|
+
mutationFn: (id: string) => api.fields.delete({ workspaceId, id }),
|
|
71
|
+
onSuccess: () => {
|
|
72
|
+
confirmingDelete = null
|
|
73
|
+
invalidate()
|
|
74
|
+
},
|
|
75
|
+
onError: fail,
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Archiving, which the page could not do.
|
|
80
|
+
*
|
|
81
|
+
* Deleting a field strips its value off every work item; archiving takes it off every form and
|
|
82
|
+
* leaves what was already recorded. Almost every "we do not use this any more" is the second one,
|
|
83
|
+
* and it was the one that had no control.
|
|
84
|
+
*/
|
|
85
|
+
const archive = createMutation(() => ({
|
|
86
|
+
mutationFn: (input: { id: string; archived: boolean }) => api.fields.archive({ workspaceId, ...input }),
|
|
87
|
+
onSuccess: invalidate,
|
|
88
|
+
onError: fail,
|
|
89
|
+
}))
|
|
90
|
+
|
|
91
|
+
const openNew = () => {
|
|
92
|
+
editing = null
|
|
93
|
+
editorOpen = true
|
|
94
|
+
}
|
|
95
|
+
const openEdit = (field: FieldDef) => {
|
|
96
|
+
editing = field
|
|
97
|
+
editorOpen = true
|
|
98
|
+
}
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<SettingsPage title={t('settings_fields')} description={t('settings_fields_hint')}>
|
|
102
|
+
{#snippet actions()}
|
|
103
|
+
<Checkbox
|
|
104
|
+
checked={showArchived}
|
|
105
|
+
label={t('field_show_archived')}
|
|
106
|
+
onCheckedChange={(on: boolean) => (showArchived = on)}
|
|
107
|
+
/>
|
|
108
|
+
{#if canManage}
|
|
109
|
+
<Button size="sm" onclick={openNew} data-testid="new-field">{t('field_new')}</Button>
|
|
110
|
+
{/if}
|
|
111
|
+
{/snippet}
|
|
112
|
+
|
|
113
|
+
<SettingsSection flush>
|
|
114
|
+
{#if fieldsQuery.isPending}
|
|
115
|
+
<div class="state"><Spinner /></div>
|
|
116
|
+
{:else if fieldsQuery.isError}
|
|
117
|
+
<div class="state">
|
|
118
|
+
<p>{t('settings_fields_failed')}</p>
|
|
119
|
+
<Button size="sm" variant="ghost" onclick={() => fieldsQuery.refetch()}>{t('common.retry')}</Button>
|
|
120
|
+
</div>
|
|
121
|
+
{:else if !fields.length}
|
|
122
|
+
<EmptyState
|
|
123
|
+
icon="tag"
|
|
124
|
+
title={t('settings_fields_empty')}
|
|
125
|
+
description={t('settings_fields_empty_hint')}
|
|
126
|
+
/>
|
|
127
|
+
{:else}
|
|
128
|
+
<ul class="rows" data-testid="field-list">
|
|
129
|
+
{#each fields as field (field.id)}
|
|
130
|
+
<li>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
class="main"
|
|
134
|
+
disabled={!canManage}
|
|
135
|
+
onclick={() => openEdit(field)}
|
|
136
|
+
data-testid="field-row"
|
|
137
|
+
data-field-key={field.key}
|
|
138
|
+
>
|
|
139
|
+
<span class="fname">{field.name}</span>
|
|
140
|
+
<span class="fkey">cf.{field.key}</span>
|
|
141
|
+
<span class="ftype">{field.type}</span>
|
|
142
|
+
{#if field.required}<Badge tone="warning">{t('field_required')}</Badge>{/if}
|
|
143
|
+
{#if field.projectId}<Badge>{t('field_project_scoped')}</Badge>{/if}
|
|
144
|
+
{#if field.archivedAt}<Badge tone="grey">{t('field_archived')}</Badge>{/if}
|
|
145
|
+
</button>
|
|
146
|
+
{#if canManage}
|
|
147
|
+
{#if field.archivedAt}
|
|
148
|
+
<Button
|
|
149
|
+
size="sm"
|
|
150
|
+
variant="ghost"
|
|
151
|
+
onclick={() => archive.mutate({ id: field.id, archived: false })}
|
|
152
|
+
>
|
|
153
|
+
{t('field_restore')}
|
|
154
|
+
</Button>
|
|
155
|
+
{:else}
|
|
156
|
+
<Button
|
|
157
|
+
size="sm"
|
|
158
|
+
variant="ghost"
|
|
159
|
+
onclick={() => archive.mutate({ id: field.id, archived: true })}
|
|
160
|
+
data-testid="field-archive"
|
|
161
|
+
>
|
|
162
|
+
{t('common.archive')}
|
|
163
|
+
</Button>
|
|
164
|
+
{/if}
|
|
165
|
+
<Button
|
|
166
|
+
size="sm"
|
|
167
|
+
variant="ghost"
|
|
168
|
+
onclick={() => (confirmingDelete = field)}
|
|
169
|
+
data-testid="field-delete"
|
|
170
|
+
>
|
|
171
|
+
{t('common.delete')}
|
|
172
|
+
</Button>
|
|
173
|
+
{/if}
|
|
174
|
+
</li>
|
|
175
|
+
{/each}
|
|
176
|
+
</ul>
|
|
177
|
+
{/if}
|
|
178
|
+
</SettingsSection>
|
|
179
|
+
|
|
180
|
+
{#if confirmingDelete}
|
|
181
|
+
<!-- Inline rather than window.confirm, and it names the consequence: deleting a field strips
|
|
182
|
+
its value from every issue that has one, which no undo brings back. -->
|
|
183
|
+
<SettingsSection tone="danger" title={t('field_delete_title', { name: confirmingDelete.name })}>
|
|
184
|
+
<p class="warn">{t('field_delete_body')}</p>
|
|
185
|
+
<div class="row">
|
|
186
|
+
<Button
|
|
187
|
+
size="sm"
|
|
188
|
+
variant="danger"
|
|
189
|
+
loading={remove.isPending}
|
|
190
|
+
onclick={() => remove.mutate(confirmingDelete!.id)}
|
|
191
|
+
data-testid="field-delete-confirm"
|
|
192
|
+
>
|
|
193
|
+
{t('common.delete')}
|
|
194
|
+
</Button>
|
|
195
|
+
<Button size="sm" variant="ghost" onclick={() => (confirmingDelete = null)}>{t('common.cancel')}</Button>
|
|
196
|
+
</div>
|
|
197
|
+
</SettingsSection>
|
|
198
|
+
{/if}
|
|
199
|
+
</SettingsPage>
|
|
200
|
+
|
|
201
|
+
<FieldEditor
|
|
202
|
+
open={editorOpen}
|
|
203
|
+
field={editing}
|
|
204
|
+
busy={save.isPending}
|
|
205
|
+
onclose={() => {
|
|
206
|
+
editorOpen = false
|
|
207
|
+
editing = null
|
|
208
|
+
}}
|
|
209
|
+
onsave={(input) => save.mutate(input)}
|
|
210
|
+
/>
|
|
211
|
+
|
|
212
|
+
<style>
|
|
213
|
+
.state {
|
|
214
|
+
display: grid;
|
|
215
|
+
place-items: center;
|
|
216
|
+
gap: 8px;
|
|
217
|
+
padding: 32px;
|
|
218
|
+
font-size: 13px;
|
|
219
|
+
}
|
|
220
|
+
.rows {
|
|
221
|
+
list-style: none;
|
|
222
|
+
margin: 0;
|
|
223
|
+
padding: 0;
|
|
224
|
+
}
|
|
225
|
+
.rows li {
|
|
226
|
+
display: flex;
|
|
227
|
+
align-items: center;
|
|
228
|
+
gap: 8px;
|
|
229
|
+
padding-inline-end: 12px;
|
|
230
|
+
border-bottom: 1px solid var(--kern-border-hairline);
|
|
231
|
+
}
|
|
232
|
+
.rows li:last-child {
|
|
233
|
+
border-bottom: 0;
|
|
234
|
+
}
|
|
235
|
+
.main {
|
|
236
|
+
display: flex;
|
|
237
|
+
align-items: center;
|
|
238
|
+
gap: 10px;
|
|
239
|
+
flex: 1;
|
|
240
|
+
min-width: 0;
|
|
241
|
+
padding: 10px 18px;
|
|
242
|
+
border: 0;
|
|
243
|
+
background: none;
|
|
244
|
+
color: inherit;
|
|
245
|
+
font: inherit;
|
|
246
|
+
text-align: start;
|
|
247
|
+
cursor: pointer;
|
|
248
|
+
}
|
|
249
|
+
.main:disabled {
|
|
250
|
+
cursor: default;
|
|
251
|
+
}
|
|
252
|
+
.main:not(:disabled):hover {
|
|
253
|
+
background: var(--kern-surface-hover);
|
|
254
|
+
}
|
|
255
|
+
.fname {
|
|
256
|
+
font-size: 13px;
|
|
257
|
+
color: var(--kern-ink-800);
|
|
258
|
+
}
|
|
259
|
+
.fkey,
|
|
260
|
+
.ftype {
|
|
261
|
+
font-family: var(--kern-font-mono);
|
|
262
|
+
font-size: 11.5px;
|
|
263
|
+
color: var(--kern-ink-400);
|
|
264
|
+
}
|
|
265
|
+
.warn {
|
|
266
|
+
margin: 0 0 10px;
|
|
267
|
+
font-size: 13px;
|
|
268
|
+
}
|
|
269
|
+
.row {
|
|
270
|
+
display: flex;
|
|
271
|
+
gap: 8px;
|
|
272
|
+
}
|
|
273
|
+
</style>
|