@kernhq/module-tracker 0.10.1 → 0.11.1
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 +19 -6
- 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/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,293 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
Icon,
|
|
6
|
+
IconButton,
|
|
7
|
+
Input,
|
|
8
|
+
navigation,
|
|
9
|
+
Select,
|
|
10
|
+
SettingsPage,
|
|
11
|
+
SettingsSection,
|
|
12
|
+
Spinner,
|
|
13
|
+
session,
|
|
14
|
+
} from '@kernhq/ui'
|
|
15
|
+
import { createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
16
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
17
|
+
import { t } from '../i18n.js'
|
|
18
|
+
import type { RecurrenceRule } from '../index.js'
|
|
19
|
+
import { canTracker } from '../permissions.js'
|
|
20
|
+
import { trackerKeys } from '../query.js'
|
|
21
|
+
import { describeRecurrence, type RecurrenceStrings } from '../recurrence.js'
|
|
22
|
+
import { listMutation } from './mutations'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Work that comes back: issue templates, and issues that create themselves.
|
|
26
|
+
*
|
|
27
|
+
* Both had a server and no screen. A recurring issue in particular is something you set up once and
|
|
28
|
+
* then have to trust — so what it will do is spelled out in a sentence, and when it last ran and
|
|
29
|
+
* what it made are shown beside it, because a schedule you cannot check is a schedule you cannot
|
|
30
|
+
* rely on.
|
|
31
|
+
*/
|
|
32
|
+
const api = getTrackerApi()
|
|
33
|
+
const queryClient = useQueryClient()
|
|
34
|
+
|
|
35
|
+
const slug = $derived(navigation.workspaceSlug)
|
|
36
|
+
const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
|
|
37
|
+
const canManage = $derived(canTracker('projectManage'))
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Which project the page is on, and how a link can say so.
|
|
41
|
+
*
|
|
42
|
+
* `?project=<id>` is what the sidebar's project menu links to — landing on the project you clicked
|
|
43
|
+
* rather than on whichever one happens to be first. An id for a project this workspace does not
|
|
44
|
+
* have is ignored rather than obeyed, so a stale link opens the page instead of an empty one.
|
|
45
|
+
*/
|
|
46
|
+
let selectedProject = $state<string | null>(null)
|
|
47
|
+
const asked = $derived(navigation.search.project)
|
|
48
|
+
let newName = $state('')
|
|
49
|
+
let freq = $state<RecurrenceRule['freq']>('weekly')
|
|
50
|
+
let at = $state('09:00')
|
|
51
|
+
|
|
52
|
+
const projectsQuery = createQuery(() => ({
|
|
53
|
+
queryKey: trackerKeys.projects(workspaceId),
|
|
54
|
+
queryFn: () => api.projects.list({ workspaceId }),
|
|
55
|
+
enabled: Boolean(workspaceId),
|
|
56
|
+
}))
|
|
57
|
+
const projects = $derived(projectsQuery.data ?? [])
|
|
58
|
+
const projectId = $derived(
|
|
59
|
+
selectedProject ?? (asked && projects.some((p) => p.id === asked) ? asked : (projects[0]?.id ?? '')),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const templatesQuery = createQuery(() => ({
|
|
63
|
+
queryKey: [...trackerKeys.projects(workspaceId), 'issue-templates', projectId],
|
|
64
|
+
queryFn: () => api.issues.templates.list({ workspaceId, projectId }),
|
|
65
|
+
enabled: Boolean(projectId),
|
|
66
|
+
}))
|
|
67
|
+
const recurringQuery = createQuery(() => ({
|
|
68
|
+
queryKey: [...trackerKeys.projects(workspaceId), 'recurring', projectId],
|
|
69
|
+
queryFn: () => api.issues.recurring.list({ workspaceId, projectId }),
|
|
70
|
+
enabled: Boolean(projectId),
|
|
71
|
+
}))
|
|
72
|
+
|
|
73
|
+
const refresh = () => queryClient.invalidateQueries({ queryKey: trackerKeys.projects(workspaceId) })
|
|
74
|
+
|
|
75
|
+
const addRecurring = listMutation(
|
|
76
|
+
(input: { name: string; rule: RecurrenceRule }) =>
|
|
77
|
+
api.issues.recurring.create({
|
|
78
|
+
workspaceId,
|
|
79
|
+
projectId,
|
|
80
|
+
name: input.name,
|
|
81
|
+
rule: input.rule,
|
|
82
|
+
defaults: { title: input.name },
|
|
83
|
+
} as never),
|
|
84
|
+
refresh,
|
|
85
|
+
)
|
|
86
|
+
const toggleRecurring = listMutation(
|
|
87
|
+
(input: { id: string; enabled: boolean }) =>
|
|
88
|
+
api.issues.recurring.update({ workspaceId, id: input.id, patch: { enabled: input.enabled } }),
|
|
89
|
+
refresh,
|
|
90
|
+
)
|
|
91
|
+
const removeRecurring = listMutation(
|
|
92
|
+
(id: string) => api.issues.recurring.delete({ workspaceId, id }),
|
|
93
|
+
refresh,
|
|
94
|
+
)
|
|
95
|
+
const removeTemplate = listMutation((id: string) => api.issues.templates.delete({ workspaceId, id }), refresh)
|
|
96
|
+
|
|
97
|
+
/** The wording, handed to the describer so the sentence can be built and tested separately. */
|
|
98
|
+
const strings: RecurrenceStrings = {
|
|
99
|
+
every: (unit) => t('recur_every', { unit }),
|
|
100
|
+
everyN: (n, unit) => t('recur_every_n', { n, unit }),
|
|
101
|
+
day: t('recur_day'),
|
|
102
|
+
week: t('recur_week'),
|
|
103
|
+
month: t('recur_month'),
|
|
104
|
+
year: t('recur_year'),
|
|
105
|
+
on: (when, days) => t('recur_on', { when, days }),
|
|
106
|
+
dayOfMonth: (day) => t('recur_day_of_month', { day }),
|
|
107
|
+
at: (when, time) => t('recur_at', { when, time }),
|
|
108
|
+
times: (text, count) => t('recur_times', { text, count }),
|
|
109
|
+
until: (text, date) => t('recur_until', { text, date }),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const add = () => {
|
|
113
|
+
const name = newName.trim()
|
|
114
|
+
if (!name) return
|
|
115
|
+
addRecurring.mutate({ name, rule: { freq, interval: 1, at } as RecurrenceRule })
|
|
116
|
+
newName = ''
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const when = (iso: string | null) => (iso ? iso.slice(0, 16).replace('T', ' ') : t('repeat_never'))
|
|
120
|
+
</script>
|
|
121
|
+
|
|
122
|
+
<SettingsPage title={t('settings_repeating')} description={t('settings_repeating_hint')}>
|
|
123
|
+
{#if projectsQuery.isPending}
|
|
124
|
+
<SettingsSection><div class="state"><Spinner /></div></SettingsSection>
|
|
125
|
+
{:else if !projects.length}
|
|
126
|
+
<SettingsSection><p class="state">{t('settings_planning_no_projects')}</p></SettingsSection>
|
|
127
|
+
{:else}
|
|
128
|
+
<SettingsSection title={t('settings_planning_project')}>
|
|
129
|
+
<Select
|
|
130
|
+
value={projectId}
|
|
131
|
+
options={projects.map((p) => ({ value: p.id, label: `${p.key} · ${p.name}` }))}
|
|
132
|
+
onValueChange={(v: string) => (selectedProject = v)}
|
|
133
|
+
/>
|
|
134
|
+
</SettingsSection>
|
|
135
|
+
|
|
136
|
+
<SettingsSection title={t('repeat_title')} description={t('repeat_hint')}>
|
|
137
|
+
{#if recurringQuery.isPending}
|
|
138
|
+
<div class="state"><Spinner /></div>
|
|
139
|
+
{:else}
|
|
140
|
+
<ul class="rows" data-testid="recurring-list">
|
|
141
|
+
{#each recurringQuery.data ?? [] as entry (entry.id)}
|
|
142
|
+
<li>
|
|
143
|
+
<div class="what">
|
|
144
|
+
<span class="name">{entry.name}</span>
|
|
145
|
+
<span class="rule">{describeRecurrence(entry.rule, strings)}</span>
|
|
146
|
+
<span class="ran">
|
|
147
|
+
{t('repeat_last_run', { when: when(entry.lastRunAt) })}
|
|
148
|
+
· {t('repeat_next_run', { when: when(entry.nextRunAt) })}
|
|
149
|
+
· {t('repeat_made', { count: entry.runCount })}
|
|
150
|
+
</span>
|
|
151
|
+
</div>
|
|
152
|
+
{#if !entry.enabled}<Badge>{t('repeat_paused')}</Badge>{/if}
|
|
153
|
+
{#if canManage}
|
|
154
|
+
<!-- A verb rather than a switch: "Pause" says what will happen, where a toggle
|
|
155
|
+
asks somebody to work out which way is on. -->
|
|
156
|
+
<Button
|
|
157
|
+
size="sm"
|
|
158
|
+
variant="ghost"
|
|
159
|
+
onclick={() => toggleRecurring.mutate({ id: entry.id, enabled: !entry.enabled })}
|
|
160
|
+
data-testid="recurring-toggle"
|
|
161
|
+
>
|
|
162
|
+
{entry.enabled ? t('repeat_pause') : t('repeat_resume')}
|
|
163
|
+
</Button>
|
|
164
|
+
<IconButton
|
|
165
|
+
icon="x"
|
|
166
|
+
size={22}
|
|
167
|
+
label={t('planning_remove', { name: entry.name })}
|
|
168
|
+
onclick={() => removeRecurring.mutate(entry.id)}
|
|
169
|
+
/>
|
|
170
|
+
{/if}
|
|
171
|
+
</li>
|
|
172
|
+
{:else}
|
|
173
|
+
<li class="empty">{t('repeat_empty')}</li>
|
|
174
|
+
{/each}
|
|
175
|
+
</ul>
|
|
176
|
+
|
|
177
|
+
{#if canManage}
|
|
178
|
+
<div class="add">
|
|
179
|
+
<Input bind:value={newName} placeholder={t('repeat_name')} data-testid="recurring-name" />
|
|
180
|
+
<Select
|
|
181
|
+
value={freq}
|
|
182
|
+
options={[
|
|
183
|
+
{ value: 'daily', label: t('recur_day') },
|
|
184
|
+
{ value: 'weekly', label: t('recur_week') },
|
|
185
|
+
{ value: 'monthly', label: t('recur_month') },
|
|
186
|
+
]}
|
|
187
|
+
onValueChange={(v: string) => (freq = v as RecurrenceRule['freq'])}
|
|
188
|
+
/>
|
|
189
|
+
<Input bind:value={at} type="time" aria-label={t('repeat_at')} />
|
|
190
|
+
<Button size="sm" disabled={!newName.trim()} onclick={add} data-testid="recurring-add">
|
|
191
|
+
{t('common.add')}
|
|
192
|
+
</Button>
|
|
193
|
+
</div>
|
|
194
|
+
<p class="note">{t('repeat_note')}</p>
|
|
195
|
+
{/if}
|
|
196
|
+
{/if}
|
|
197
|
+
</SettingsSection>
|
|
198
|
+
|
|
199
|
+
<SettingsSection title={t('template_title')} description={t('template_hint')}>
|
|
200
|
+
{#if templatesQuery.isPending}
|
|
201
|
+
<div class="state"><Spinner /></div>
|
|
202
|
+
{:else}
|
|
203
|
+
<ul class="rows" data-testid="template-list">
|
|
204
|
+
{#each templatesQuery.data ?? [] as template (template.id)}
|
|
205
|
+
<li>
|
|
206
|
+
<div class="what">
|
|
207
|
+
<span class="name">{template.name}</span>
|
|
208
|
+
{#if template.description}<span class="rule">{template.description}</span>{/if}
|
|
209
|
+
{#if template.subItems.length}
|
|
210
|
+
<span class="ran">{t('template_subitems', { count: template.subItems.length })}</span>
|
|
211
|
+
{/if}
|
|
212
|
+
</div>
|
|
213
|
+
{#if canManage}
|
|
214
|
+
<IconButton
|
|
215
|
+
icon="x"
|
|
216
|
+
size={22}
|
|
217
|
+
label={t('planning_remove', { name: template.name })}
|
|
218
|
+
onclick={() => removeTemplate.mutate(template.id)}
|
|
219
|
+
/>
|
|
220
|
+
{/if}
|
|
221
|
+
</li>
|
|
222
|
+
{:else}
|
|
223
|
+
<li class="empty">
|
|
224
|
+
<Icon name="bookmark" size={13} strokeWidth={1.8} />
|
|
225
|
+
{t('template_empty')}
|
|
226
|
+
</li>
|
|
227
|
+
{/each}
|
|
228
|
+
</ul>
|
|
229
|
+
{/if}
|
|
230
|
+
</SettingsSection>
|
|
231
|
+
{/if}
|
|
232
|
+
</SettingsPage>
|
|
233
|
+
|
|
234
|
+
<style>
|
|
235
|
+
.state {
|
|
236
|
+
display: grid;
|
|
237
|
+
place-items: center;
|
|
238
|
+
padding: 24px;
|
|
239
|
+
font-size: 13px;
|
|
240
|
+
}
|
|
241
|
+
.rows {
|
|
242
|
+
list-style: none;
|
|
243
|
+
margin: 0;
|
|
244
|
+
padding: 0;
|
|
245
|
+
}
|
|
246
|
+
.rows li {
|
|
247
|
+
display: flex;
|
|
248
|
+
align-items: center;
|
|
249
|
+
gap: 10px;
|
|
250
|
+
padding: 8px 0;
|
|
251
|
+
border-bottom: 1px solid var(--kern-border-hairline);
|
|
252
|
+
}
|
|
253
|
+
.rows li:last-child {
|
|
254
|
+
border-bottom: 0;
|
|
255
|
+
}
|
|
256
|
+
.what {
|
|
257
|
+
display: flex;
|
|
258
|
+
flex-direction: column;
|
|
259
|
+
gap: 1px;
|
|
260
|
+
flex: 1;
|
|
261
|
+
min-width: 0;
|
|
262
|
+
}
|
|
263
|
+
.name {
|
|
264
|
+
font-size: 13px;
|
|
265
|
+
color: var(--kern-ink-800);
|
|
266
|
+
}
|
|
267
|
+
.rule {
|
|
268
|
+
font-size: 12.5px;
|
|
269
|
+
color: var(--kern-ink-550);
|
|
270
|
+
}
|
|
271
|
+
.ran {
|
|
272
|
+
font-size: 11.5px;
|
|
273
|
+
color: var(--kern-ink-400);
|
|
274
|
+
}
|
|
275
|
+
.empty {
|
|
276
|
+
display: flex;
|
|
277
|
+
align-items: center;
|
|
278
|
+
gap: 6px;
|
|
279
|
+
font-size: 13px;
|
|
280
|
+
color: var(--kern-ink-400);
|
|
281
|
+
}
|
|
282
|
+
.add {
|
|
283
|
+
display: flex;
|
|
284
|
+
gap: 8px;
|
|
285
|
+
margin-top: 12px;
|
|
286
|
+
flex-wrap: wrap;
|
|
287
|
+
}
|
|
288
|
+
.note {
|
|
289
|
+
margin: 8px 0 0;
|
|
290
|
+
font-size: 12px;
|
|
291
|
+
color: var(--kern-ink-400);
|
|
292
|
+
}
|
|
293
|
+
</style>
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Checkbox, Dialog, Icon, Input, Select, Textarea } from '@kernhq/ui'
|
|
3
|
+
import { t } from '../i18n.js'
|
|
4
|
+
import type { WorkItemType } from '../index.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creating or editing one work item type.
|
|
8
|
+
*
|
|
9
|
+
* A type's `key` is fixed after creation for the same reason a field's is: it is what imports, the
|
|
10
|
+
* API and saved queries name it by, so changing it would orphan every one of them.
|
|
11
|
+
*
|
|
12
|
+
* `level` is the one setting whose meaning is not obvious from its name, so the dialog spells out
|
|
13
|
+
* what each level does rather than showing the numbers the contract uses. It decides what may
|
|
14
|
+
* parent what — an epic can hold a story, a story cannot hold an epic.
|
|
15
|
+
*/
|
|
16
|
+
interface Props {
|
|
17
|
+
open: boolean
|
|
18
|
+
/** null when creating */
|
|
19
|
+
type: WorkItemType | null
|
|
20
|
+
busy?: boolean
|
|
21
|
+
onclose: () => void
|
|
22
|
+
onsave: (input: Record<string, unknown>) => void
|
|
23
|
+
}
|
|
24
|
+
let { open, type, busy = false, onclose, onsave }: Props = $props()
|
|
25
|
+
|
|
26
|
+
/** The registry holds 106 icons; these are the ones that mean something on a work item type. */
|
|
27
|
+
const ICONS = [
|
|
28
|
+
'square-check-big',
|
|
29
|
+
'bug',
|
|
30
|
+
'bookmark',
|
|
31
|
+
'zap',
|
|
32
|
+
'flag',
|
|
33
|
+
'target',
|
|
34
|
+
'file-text',
|
|
35
|
+
'wrench',
|
|
36
|
+
'shield',
|
|
37
|
+
'palette',
|
|
38
|
+
'circle-alert',
|
|
39
|
+
'briefcase',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const LEVELS: { value: string; label: () => string; description: () => string }[] = [
|
|
43
|
+
{ value: '2', label: () => t('level_2'), description: () => t('level_2_hint') },
|
|
44
|
+
{ value: '1', label: () => t('level_1'), description: () => t('level_1_hint') },
|
|
45
|
+
{ value: '0', label: () => t('level_0'), description: () => t('level_0_hint') },
|
|
46
|
+
{ value: '-1', label: () => t('level_sub'), description: () => t('level_sub_hint') },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
let name = $state('')
|
|
50
|
+
let key = $state('')
|
|
51
|
+
let description = $state('')
|
|
52
|
+
let icon = $state('square-check-big')
|
|
53
|
+
let level = $state('0')
|
|
54
|
+
let isDefault = $state(false)
|
|
55
|
+
/** Touched once, the key stops following the name — otherwise a rename would rewrite it. */
|
|
56
|
+
let keyEdited = $state(false)
|
|
57
|
+
|
|
58
|
+
const editing = $derived(Boolean(type))
|
|
59
|
+
|
|
60
|
+
$effect(() => {
|
|
61
|
+
if (!open) return
|
|
62
|
+
name = type?.name ?? ''
|
|
63
|
+
key = type?.key ?? ''
|
|
64
|
+
description = type?.description ?? ''
|
|
65
|
+
icon = type?.icon ?? 'square-check-big'
|
|
66
|
+
level = String(type?.level ?? 0)
|
|
67
|
+
isDefault = type?.isDefault ?? false
|
|
68
|
+
keyEdited = Boolean(type)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/** `Customer request` → `customer_request`: the key is a machine name, not a sentence. */
|
|
72
|
+
const slugify = (value: string) =>
|
|
73
|
+
value
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
76
|
+
.replace(/^_+|_+$/g, '')
|
|
77
|
+
.slice(0, 40)
|
|
78
|
+
|
|
79
|
+
$effect(() => {
|
|
80
|
+
if (!keyEdited) key = slugify(name)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const keyValid = $derived(/^[a-z][a-z0-9_]*$/.test(key))
|
|
84
|
+
const valid = $derived(Boolean(name.trim()) && keyValid)
|
|
85
|
+
|
|
86
|
+
function save() {
|
|
87
|
+
if (!valid || busy) return
|
|
88
|
+
onsave({
|
|
89
|
+
name: name.trim(),
|
|
90
|
+
description: description.trim() || null,
|
|
91
|
+
icon,
|
|
92
|
+
level: Number(level),
|
|
93
|
+
isDefault,
|
|
94
|
+
// the key is what every import, query and API caller names this type by
|
|
95
|
+
...(editing ? {} : { key }),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
<Dialog
|
|
101
|
+
{open}
|
|
102
|
+
title={editing ? t('type_edit') : t('type_new')}
|
|
103
|
+
size="md"
|
|
104
|
+
onOpenChange={(next: boolean) => {
|
|
105
|
+
if (!next) onclose()
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<div class="grid gap-3">
|
|
109
|
+
<label class="grid gap-1">
|
|
110
|
+
<span class="lbl">{t('type_name')}</span>
|
|
111
|
+
<Input bind:value={name} placeholder="Incident" data-testid="type-name" />
|
|
112
|
+
</label>
|
|
113
|
+
|
|
114
|
+
<label class="grid gap-1">
|
|
115
|
+
<span class="lbl">{t('type_key')}</span>
|
|
116
|
+
<Input
|
|
117
|
+
bind:value={key}
|
|
118
|
+
disabled={editing}
|
|
119
|
+
oninput={() => (keyEdited = true)}
|
|
120
|
+
placeholder="incident"
|
|
121
|
+
data-testid="type-key"
|
|
122
|
+
/>
|
|
123
|
+
<span class="hint" class:bad={!keyValid && key.length > 0}>
|
|
124
|
+
{editing ? t('type_key_fixed') : t('type_key_hint')}
|
|
125
|
+
</span>
|
|
126
|
+
</label>
|
|
127
|
+
|
|
128
|
+
<div class="grid gap-1">
|
|
129
|
+
<span class="lbl">{t('type_icon')}</span>
|
|
130
|
+
<!-- Twelve buttons rather than a menu of 106 names: an icon is chosen by looking at it. -->
|
|
131
|
+
<div class="icons" data-testid="type-icons">
|
|
132
|
+
{#each ICONS as candidate (candidate)}
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
class="ico"
|
|
136
|
+
class:on={icon === candidate}
|
|
137
|
+
aria-pressed={icon === candidate}
|
|
138
|
+
aria-label={candidate}
|
|
139
|
+
onclick={() => (icon = candidate)}
|
|
140
|
+
>
|
|
141
|
+
<Icon name={candidate} size={16} strokeWidth={1.8} />
|
|
142
|
+
</button>
|
|
143
|
+
{/each}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<label class="grid gap-1">
|
|
148
|
+
<span class="lbl">{t('type_level')}</span>
|
|
149
|
+
<Select
|
|
150
|
+
value={level}
|
|
151
|
+
options={LEVELS.map((l) => ({ value: l.value, label: l.label(), description: l.description() }))}
|
|
152
|
+
onValueChange={(v: string) => (level = v)}
|
|
153
|
+
/>
|
|
154
|
+
<span class="hint">{t('type_level_hint')}</span>
|
|
155
|
+
</label>
|
|
156
|
+
|
|
157
|
+
<label class="grid gap-1">
|
|
158
|
+
<span class="lbl">{t('type_description')}</span>
|
|
159
|
+
<Textarea bind:value={description} rows={2} />
|
|
160
|
+
</label>
|
|
161
|
+
|
|
162
|
+
<Checkbox
|
|
163
|
+
checked={isDefault}
|
|
164
|
+
label={t('type_make_default')}
|
|
165
|
+
onCheckedChange={(on: boolean) => (isDefault = on)}
|
|
166
|
+
/>
|
|
167
|
+
<span class="hint">{t('type_default_hint')}</span>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{#snippet footer()}
|
|
171
|
+
<Button variant="ghost" size="sm" onclick={onclose}>{t('common.cancel')}</Button>
|
|
172
|
+
<Button size="sm" disabled={!valid} loading={busy} onclick={save} data-testid="type-save">
|
|
173
|
+
{t('common.save')}
|
|
174
|
+
</Button>
|
|
175
|
+
{/snippet}
|
|
176
|
+
</Dialog>
|
|
177
|
+
|
|
178
|
+
<style>
|
|
179
|
+
.lbl {
|
|
180
|
+
font-size: 12px;
|
|
181
|
+
font-weight: 500;
|
|
182
|
+
color: var(--kern-ink-600);
|
|
183
|
+
}
|
|
184
|
+
.hint {
|
|
185
|
+
font-size: 11.5px;
|
|
186
|
+
color: var(--kern-ink-400);
|
|
187
|
+
}
|
|
188
|
+
.hint.bad {
|
|
189
|
+
color: var(--kern-danger);
|
|
190
|
+
}
|
|
191
|
+
.icons {
|
|
192
|
+
display: flex;
|
|
193
|
+
flex-wrap: wrap;
|
|
194
|
+
gap: 6px;
|
|
195
|
+
}
|
|
196
|
+
.ico {
|
|
197
|
+
display: grid;
|
|
198
|
+
place-items: center;
|
|
199
|
+
width: 32px;
|
|
200
|
+
height: 32px;
|
|
201
|
+
border: 1px solid var(--kern-border-hairline);
|
|
202
|
+
border-radius: var(--kern-r-sm);
|
|
203
|
+
background: none;
|
|
204
|
+
color: var(--kern-ink-600);
|
|
205
|
+
cursor: pointer;
|
|
206
|
+
}
|
|
207
|
+
.ico:hover {
|
|
208
|
+
background: var(--kern-surface-hover);
|
|
209
|
+
}
|
|
210
|
+
.ico.on {
|
|
211
|
+
border-color: var(--kern-accent);
|
|
212
|
+
color: var(--kern-accent);
|
|
213
|
+
}
|
|
214
|
+
</style>
|