@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.
Files changed (85) hide show
  1. package/package.json +16 -3
  2. package/src/client/api-instance.ts +34 -0
  3. package/src/client/components/BoardCard.svelte +182 -0
  4. package/src/client/components/BoardView.svelte +248 -0
  5. package/src/client/components/CommentComposer.svelte +219 -0
  6. package/src/client/components/CommentThread.svelte +389 -0
  7. package/src/client/components/CustomField.svelte +333 -0
  8. package/src/client/components/DueDate.svelte +44 -0
  9. package/src/client/components/FilterMenu.svelte +91 -0
  10. package/src/client/components/GroupHeader.svelte +137 -0
  11. package/src/client/components/HomeLinks.svelte +38 -0
  12. package/src/client/components/IssueApprovals.svelte +217 -0
  13. package/src/client/components/IssueConnections.svelte +424 -0
  14. package/src/client/components/IssueDetailPanel.svelte +1337 -0
  15. package/src/client/components/IssueInline.svelte +75 -0
  16. package/src/client/components/IssueListView.svelte +153 -0
  17. package/src/client/components/IssuePicker.svelte +180 -0
  18. package/src/client/components/IssueRow.svelte +171 -0
  19. package/src/client/components/IssueTime.svelte +306 -0
  20. package/src/client/components/KqlInput.svelte +295 -0
  21. package/src/client/components/NewIssueDialog.svelte +210 -0
  22. package/src/client/components/NewProjectDialog.svelte +235 -0
  23. package/src/client/components/PriorityGlyph.svelte +52 -0
  24. package/src/client/components/SaveViewDialog.svelte +153 -0
  25. package/src/client/components/SidebarProjects.svelte +263 -0
  26. package/src/client/components/SidebarViews.svelte +336 -0
  27. package/src/client/components/StatusIcon.svelte +50 -0
  28. package/src/client/components/TrackerControls.svelte +109 -0
  29. package/src/client/components/TrackerSidebar.svelte +96 -0
  30. package/src/client/context.svelte.ts +105 -0
  31. package/src/client/core-api.ts +35 -0
  32. package/src/client/csv.test.ts +50 -0
  33. package/src/client/csv.ts +60 -0
  34. package/src/client/filters.ts +94 -0
  35. package/src/client/i18n.ts +3260 -0
  36. package/src/client/index.ts +12 -0
  37. package/src/client/labels.ts +200 -0
  38. package/src/client/mock.ts +2729 -0
  39. package/src/client/module.ts +491 -0
  40. package/src/client/nav.test.ts +59 -0
  41. package/src/client/nav.ts +84 -0
  42. package/src/client/pages/IntakePage.svelte +287 -0
  43. package/src/client/pages/IssuesPage.svelte +701 -0
  44. package/src/client/pages/ProjectPage.svelte +29 -0
  45. package/src/client/pages/ReportsPage.svelte +365 -0
  46. package/src/client/permissions.ts +33 -0
  47. package/src/client/planning/PlanningSections.svelte +259 -0
  48. package/src/client/project/ComponentsPage.svelte +423 -0
  49. package/src/client/project/CyclesPage.svelte +488 -0
  50. package/src/client/project/MilestonesPage.svelte +342 -0
  51. package/src/client/project/PlanCard.svelte +191 -0
  52. package/src/client/project/ProjectShell.svelte +93 -0
  53. package/src/client/project/TemplatesPage.svelte +321 -0
  54. package/src/client/project/context.svelte.ts +58 -0
  55. package/src/client/query.ts +50 -0
  56. package/src/client/rank.test.ts +78 -0
  57. package/src/client/rank.ts +13 -4
  58. package/src/client/recurrence.test.ts +37 -0
  59. package/src/client/recurrence.ts +89 -0
  60. package/src/client/richtext.ts +155 -0
  61. package/src/client/settings/CycleList.svelte +422 -0
  62. package/src/client/settings/FieldEditor.svelte +324 -0
  63. package/src/client/settings/FieldsSettings.svelte +273 -0
  64. package/src/client/settings/ImportSettings.svelte +410 -0
  65. package/src/client/settings/LayoutEditor.svelte +263 -0
  66. package/src/client/settings/PlanningList.svelte +249 -0
  67. package/src/client/settings/PlanningSettings.svelte +179 -0
  68. package/src/client/settings/ProjectsSettings.svelte +462 -0
  69. package/src/client/settings/RepeatingSettings.svelte +293 -0
  70. package/src/client/settings/TypeEditor.svelte +214 -0
  71. package/src/client/settings/TypesSettings.svelte +384 -0
  72. package/src/client/settings/WorkflowsSettings.svelte +645 -0
  73. package/src/client/settings/mutations.ts +19 -0
  74. package/src/client/time.test.ts +39 -0
  75. package/src/client/time.ts +34 -0
  76. package/src/client/tracker.test.ts +399 -0
  77. package/src/client/views.ts +27 -0
  78. package/src/client/widgets/AssignedCountWidget.svelte +14 -0
  79. package/src/client/widgets/CountWidget.svelte +56 -0
  80. package/src/client/widgets/CycleWidget.svelte +85 -0
  81. package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
  82. package/src/client/widgets/IssuesWidget.svelte +222 -0
  83. package/src/client/widgets/ThroughputWidget.svelte +68 -0
  84. package/src/client/widgets/TimerWidget.svelte +116 -0
  85. package/src/client/widgets/VelocityWidget.svelte +55 -0
@@ -0,0 +1,645 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Checkbox,
6
+ Dialog,
7
+ Icon,
8
+ IconButton,
9
+ Input,
10
+ navigation,
11
+ Select,
12
+ SettingsPage,
13
+ SettingsSection,
14
+ Spinner,
15
+ session,
16
+ toast,
17
+ } from '@kernhq/ui'
18
+ import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
19
+ import { getTrackerApi } from '../api-instance.js'
20
+ import { t } from '../i18n.js'
21
+ import type { StatusCategory, Workflow, WorkflowDefinition } from '../index.js'
22
+ import { describeApprovers, describeRule } from '../index.js'
23
+ import { canTracker } from '../permissions.js'
24
+ import { trackerKeys } from '../query.js'
25
+
26
+ /**
27
+ * Workflows: the statuses work moves through, and what each move requires.
28
+ *
29
+ * The statuses are editable here. The transitions are shown rather than edited — every one of them
30
+ * carries conditions, validators and post-functions, and a graph editor for those is its own piece
31
+ * of work. What matters first is that they stop being invisible: a workflow's rules decided who
32
+ * could close an issue and nobody could read them.
33
+ *
34
+ * A workflow starts from one of the three the server ships rather than from nothing. An empty
35
+ * workflow has no transitions, so no work could ever move through it — offering "blank" would be
36
+ * offering a broken thing.
37
+ */
38
+ const api = getTrackerApi()
39
+ const queryClient = useQueryClient()
40
+
41
+ const slug = $derived(navigation.workspaceSlug)
42
+ const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
43
+ const canManage = $derived(canTracker('workflowManage'))
44
+
45
+ let selectedId = $state<string | null>(null)
46
+ /** The statuses being edited, or `null` when nothing has been touched — which is what gates Save. */
47
+ let draft = $state<WorkflowDefinition['statuses'] | null>(null)
48
+ let adding = $state('')
49
+ let addingCategory = $state<StatusCategory>('todo')
50
+ let newOpen = $state(false)
51
+ let newName = $state('')
52
+ let newTemplate = $state('software')
53
+ let removingId = $state<string | null>(null)
54
+ /** What the server said about the arrangement in progress; `[]` once it has said nothing is wrong. */
55
+ let problems = $state<Array<{ path: string; message: string }>>([])
56
+ /** Transitions a newly added status needs; they join the definition only when it is saved. */
57
+ let pendingTransitions = $state<WorkflowDefinition['transitions']>([])
58
+
59
+ const workflowsQuery = createQuery(() => ({
60
+ queryKey: [...trackerKeys.types(workspaceId), 'workflows'],
61
+ queryFn: () => api.workflows.list({ workspaceId }),
62
+ enabled: Boolean(workspaceId),
63
+ }))
64
+ const typesQuery = createQuery(() => ({
65
+ queryKey: trackerKeys.types(workspaceId),
66
+ queryFn: () => api.types.list({ workspaceId, includeArchived: false }),
67
+ enabled: Boolean(workspaceId),
68
+ }))
69
+
70
+ const templatesQuery = createQuery(() => ({
71
+ queryKey: ['tracker', 'workflow-template'],
72
+ queryFn: () => api.workflows.templates({}),
73
+ enabled: Boolean(workspaceId),
74
+ }))
75
+
76
+ const workflows = $derived(workflowsQuery.data ?? [])
77
+ const selected = $derived(workflows.find((w) => w.id === selectedId) ?? workflows[0] ?? null)
78
+ const statuses = $derived(draft ?? selected?.definition.statuses ?? [])
79
+
80
+ /**
81
+ * The definition as it would be saved.
82
+ *
83
+ * Removing a status has to take its transitions with it: a transition naming a status that is no
84
+ * longer there fails validation, and the message an admin would see names a path index rather than
85
+ * the status they just removed.
86
+ */
87
+ const nextDefinition = $derived.by(() => {
88
+ if (!selected) return null
89
+ const ids = new Set(statuses.map((st) => st.id))
90
+ return {
91
+ ...selected.definition,
92
+ statuses: statuses.map((st, order) => ({ ...st, order })),
93
+ transitions: [...selected.definition.transitions, ...pendingTransitions]
94
+ .filter((rule) => ids.has(rule.to) && (rule.from === '*' || rule.from.some((f) => ids.has(f))))
95
+ .map((rule) => (rule.from === '*' ? rule : { ...rule, from: rule.from.filter((f) => ids.has(f)) })),
96
+ }
97
+ })
98
+ /** Which work item types this workflow governs — the reason editing it is not a local decision. */
99
+ const usedBy = $derived((typesQuery.data ?? []).filter((type) => type.workflowId === selected?.id))
100
+
101
+ const refresh = () => {
102
+ void queryClient.invalidateQueries({ queryKey: trackerKeys.types(workspaceId) })
103
+ void queryClient.invalidateQueries({ queryKey: trackerKeys.statuses(workspaceId) })
104
+ }
105
+
106
+ /**
107
+ * Checked by the server before it is saved, not after it is rejected.
108
+ *
109
+ * The rules live in `@kernhq/workflow` and the same call the server makes on save is available on
110
+ * its own — so the page can say what is wrong in the workflow's own terms instead of turning a
111
+ * rejected save into a toast.
112
+ *
113
+ * Note what it does not check: whether a status can be reached. That is why adding one wires it in
114
+ * rather than leaving it floating.
115
+ */
116
+ const save = createMutation(() => ({
117
+ mutationFn: async (definition: WorkflowDefinition) => {
118
+ const check = await api.workflows.validate({ workspaceId, definition })
119
+ if (!check.ok) {
120
+ problems = check.problems
121
+ throw new Error(t('workflow_invalid'))
122
+ }
123
+ problems = []
124
+ return api.workflows.update({ workspaceId, id: selected!.id, patch: { definition } } as never)
125
+ },
126
+ onSuccess: () => {
127
+ draft = null
128
+ pendingTransitions = []
129
+ refresh()
130
+ toast.success(t('workflow_saved'))
131
+ },
132
+ onError: (error: Error) => toast.error(error.message),
133
+ }))
134
+
135
+ const createWorkflow = createMutation(() => ({
136
+ mutationFn: () => {
137
+ const template = (templatesQuery.data ?? []).find((tpl) => tpl.id === newTemplate)
138
+ if (!template) throw new Error(t('workflow_no_template'))
139
+ return api.workflows.create({
140
+ workspaceId,
141
+ name: newName.trim(),
142
+ definition: template.definition,
143
+ } as never)
144
+ },
145
+ onSuccess: (created: Workflow) => {
146
+ newOpen = false
147
+ newName = ''
148
+ selectedId = created.id
149
+ draft = null
150
+ refresh()
151
+ },
152
+ onError: (error: Error) => toast.error(error.message),
153
+ }))
154
+
155
+ const archiveWorkflow = createMutation(() => ({
156
+ mutationFn: (input: { id: string; archived: boolean }) => api.workflows.archive({ workspaceId, ...input }),
157
+ onSuccess: refresh,
158
+ onError: (error: Error) => toast.error(error.message),
159
+ }))
160
+
161
+ /**
162
+ * Which types run on this workflow.
163
+ *
164
+ * Creating a workflow means nothing until something uses it, and the type is what points at one —
165
+ * so the link is made here, where the workflow is, rather than left to be found on another screen.
166
+ */
167
+ const setTypeWorkflow = createMutation(() => ({
168
+ mutationFn: (input: { typeId: string; workflowId: string | null }) =>
169
+ api.types.update({ workspaceId, id: input.typeId, patch: { workflowId: input.workflowId } }),
170
+ onSuccess: refresh,
171
+ onError: (error: Error) => toast.error(error.message),
172
+ }))
173
+
174
+ const select = (workflow: Workflow) => {
175
+ selectedId = workflow.id
176
+ draft = null
177
+ problems = []
178
+ removingId = null
179
+ pendingTransitions = []
180
+ }
181
+
182
+ const rename = (id: string, name: string) => {
183
+ draft = statuses.map((s) => (s.id === id ? { ...s, name } : s))
184
+ }
185
+
186
+ /**
187
+ * A status nothing can move into is a status that does not exist, and validation does not catch
188
+ * it — so a new one is wired in on both sides: work reaches it from anywhere, and leaves it for
189
+ * anywhere. Permissive on purpose. A status that is too open is a rule to tighten later; one that
190
+ * traps work is a support ticket.
191
+ */
192
+ const addStatus = () => {
193
+ const name = adding.trim()
194
+ if (!name || !selected) return
195
+ const id = `st_${name.toLowerCase().replace(/[^a-z0-9]+/g, '_')}_${statuses.length}`
196
+ draft = [...statuses, { id, name, category: addingCategory, order: statuses.length }]
197
+ // Every array present: a transition missing `validators` is not "no validators" to the contract,
198
+ // it is an incomplete transition, and the definition fails to parse.
199
+ const empty = { conditions: [], validators: [], postFunctions: [], hidden: false }
200
+ const into = { id: `tr_into_${id}`, name, from: '*' as const, to: id, ...empty }
201
+ const outOf = statuses.map((st) => ({
202
+ id: `tr_${id}_${st.id}`,
203
+ name: st.name,
204
+ from: [id],
205
+ to: st.id,
206
+ ...empty,
207
+ }))
208
+ pendingTransitions = [...pendingTransitions, into, ...outOf]
209
+ adding = ''
210
+ }
211
+
212
+ /** Removing takes the transitions with it, here and in `nextDefinition`; the server refuses if
213
+ * any work is still sitting in the status, and says so. */
214
+ const removeStatus = (id: string) => {
215
+ draft = statuses.filter((st) => st.id !== id)
216
+ pendingTransitions = pendingTransitions.filter(
217
+ (rule) => rule.to !== id && (rule.from === '*' || !rule.from.includes(id)),
218
+ )
219
+ removingId = null
220
+ }
221
+ const move = (index: number, by: number) => {
222
+ const next = [...statuses]
223
+ const target = index + by
224
+ if (target < 0 || target >= next.length) return
225
+ const [moved] = next.splice(index, 1)
226
+ next.splice(target, 0, moved!)
227
+ draft = next
228
+ }
229
+
230
+ const CATEGORIES: StatusCategory[] = ['triage', 'backlog', 'todo', 'in_progress', 'done', 'cancelled']
231
+ const CATEGORY_LABELS: Record<StatusCategory, () => string> = {
232
+ triage: () => t('cat_triage'),
233
+ backlog: () => t('cat_backlog'),
234
+ todo: () => t('cat_todo'),
235
+ in_progress: () => t('cat_in_progress'),
236
+ done: () => t('cat_done'),
237
+ cancelled: () => t('cat_cancelled'),
238
+ }
239
+ const categoryLabel = (c: StatusCategory) => CATEGORY_LABELS[c]?.() ?? c
240
+
241
+ const statusName = (id: string) => statuses.find((s) => s.id === id)?.name ?? id
242
+ const from = (rule: { from: string | string[] }) =>
243
+ rule.from === '*' ? t('workflow_from_any') : (rule.from as string[]).map(statusName).join(', ')
244
+ </script>
245
+
246
+ <SettingsPage title={t('settings_workflows')} description={t('settings_workflows_hint')}>
247
+ {#if workflowsQuery.isPending}
248
+ <SettingsSection><div class="state"><Spinner /></div></SettingsSection>
249
+ {:else if !workflows.length}
250
+ <SettingsSection><p class="state">{t('settings_workflows_empty')}</p></SettingsSection>
251
+ {:else}
252
+ <SettingsSection flush>
253
+ <ul class="wlist" data-testid="workflow-list">
254
+ {#each workflows as workflow (workflow.id)}
255
+ <li>
256
+ <button
257
+ type="button"
258
+ class="wrow"
259
+ class:on={selected?.id === workflow.id}
260
+ onclick={() => select(workflow)}
261
+ data-workflow={workflow.name}
262
+ >
263
+ <span class="wname">{workflow.name}</span>
264
+ <span class="wmeta">{t('workflow_status_count', { count: workflow.definition.statuses.length })}</span>
265
+ {#if workflow.isDefault}<Badge>{t('type_default')}</Badge>{/if}
266
+ </button>
267
+ {#if canManage && !workflow.isDefault}
268
+ <Button
269
+ size="sm"
270
+ variant="ghost"
271
+ onclick={() => archiveWorkflow.mutate({ id: workflow.id, archived: true })}
272
+ >
273
+ {t('common.archive')}
274
+ </Button>
275
+ {/if}
276
+ </li>
277
+ {/each}
278
+ </ul>
279
+
280
+ {#snippet footer()}
281
+ <Button size="sm" disabled={!canManage} onclick={() => (newOpen = true)} data-testid="workflow-new">
282
+ {t('workflow_new')}
283
+ </Button>
284
+ {/snippet}
285
+ </SettingsSection>
286
+
287
+ {#if selected}
288
+ <!-- Every section below names the workflow it belongs to. The list is at the top of a page
289
+ that scrolls well past it, so by the time you reach Save the only thing telling you which
290
+ workflow you are editing has scrolled away. -->
291
+ <SettingsSection
292
+ title={t('workflow_statuses_in', { name: selected.name })}
293
+ description={t('workflow_statuses_hint')}
294
+ >
295
+ <ul class="statuses" data-testid="workflow-statuses">
296
+ {#each statuses as status, i (status.id)}
297
+ <li>
298
+ <span class="ord">{i + 1}</span>
299
+ {#if canManage}
300
+ <Input
301
+ value={status.name}
302
+ aria-label={t('status_name')}
303
+ data-status={status.id}
304
+ oninput={(e: Event) => rename(status.id, (e.currentTarget as HTMLInputElement).value)}
305
+ />
306
+ <!-- The category, not the name, is what a board column and every report count by,
307
+ so it is shown beside the name rather than hidden in the definition. Fixed
308
+ width, or the buttons after it sit in a ragged column. -->
309
+ <span class="cat"><Badge tone="grey">{categoryLabel(status.category)}</Badge></span>
310
+ <IconButton
311
+ icon="chevron-up"
312
+ size={22}
313
+ label={t('workflow_move_up', { name: status.name })}
314
+ disabled={i === 0}
315
+ onclick={() => move(i, -1)}
316
+ />
317
+ <IconButton
318
+ icon="chevron-down"
319
+ size={22}
320
+ label={t('workflow_move_down', { name: status.name })}
321
+ disabled={i === statuses.length - 1}
322
+ onclick={() => move(i, 1)}
323
+ />
324
+ <IconButton
325
+ icon="x"
326
+ size={22}
327
+ label={t('workflow_remove_status', { name: status.name })}
328
+ disabled={statuses.length < 2}
329
+ onclick={() => (removingId = status.id)}
330
+ />
331
+ {:else}
332
+ <span class="sname">{status.name}</span>
333
+ <span class="cat"><Badge tone="grey">{categoryLabel(status.category)}</Badge></span>
334
+ {/if}
335
+ </li>
336
+ {#if removingId === status.id}
337
+ <li class="confirm">
338
+ <div role="alertdialog" aria-label={t('workflow_remove_body')}>
339
+ <span>{t('workflow_remove_body')}</span>
340
+ <Button size="sm" variant="danger" onclick={() => removeStatus(status.id)}>
341
+ {t('common.remove')}
342
+ </Button>
343
+ <Button size="sm" variant="ghost" onclick={() => (removingId = null)}>
344
+ {t('common.cancel')}
345
+ </Button>
346
+ </div>
347
+ </li>
348
+ {/if}
349
+ {/each}
350
+ </ul>
351
+
352
+ {#if canManage}
353
+ <form class="addrow" onsubmit={(e) => { e.preventDefault(); addStatus() }}>
354
+ <Input bind:value={adding} placeholder={t('workflow_status_add')} data-testid="status-add" />
355
+ <Select
356
+ value={addingCategory}
357
+ options={CATEGORIES.map((c) => ({ value: c, label: categoryLabel(c) }))}
358
+ onValueChange={(v: string) => (addingCategory = v as StatusCategory)}
359
+ />
360
+ <Button size="sm" disabled={!adding.trim()} onclick={addStatus} data-testid="status-add-go">
361
+ {t('common.add')}
362
+ </Button>
363
+ </form>
364
+ <p class="note">{t('workflow_status_add_hint')}</p>
365
+ {/if}
366
+
367
+ {#if problems.length}
368
+ <ul class="problems" data-testid="workflow-problems">
369
+ {#each problems as problem (problem.path + problem.message)}
370
+ <li>{problem.path}: {problem.message}</li>
371
+ {/each}
372
+ </ul>
373
+ {/if}
374
+
375
+ <div class="usedby">
376
+ <p class="lbl">{t('workflow_used_by_in', { name: selected.name })}</p>
377
+ {#each typesQuery.data ?? [] as type (type.id)}
378
+ <Checkbox
379
+ checked={type.workflowId === selected.id}
380
+ label={type.name}
381
+ disabled={!canManage}
382
+ onCheckedChange={(on: boolean) =>
383
+ setTypeWorkflow.mutate({ typeId: type.id, workflowId: on ? selected.id : null })}
384
+ />
385
+ {/each}
386
+ {#if !usedBy.length}
387
+ <p class="note">{t('workflow_used_by_none')}</p>
388
+ {/if}
389
+ </div>
390
+
391
+ {#snippet footer()}
392
+ <Button variant="ghost" size="sm" onclick={() => (draft = null)} disabled={!draft}>
393
+ {t('common.discard')}
394
+ </Button>
395
+ <Button
396
+ size="sm"
397
+ disabled={!draft || !canManage}
398
+ loading={save.isPending}
399
+ onclick={() => nextDefinition && save.mutate(nextDefinition)}
400
+ data-testid="save-workflow"
401
+ >
402
+ {t('common.save')}
403
+ </Button>
404
+ {/snippet}
405
+ </SettingsSection>
406
+
407
+ <SettingsSection
408
+ title={t('workflow_transitions_in', { name: selected.name })}
409
+ description={t('workflow_transitions_hint')}
410
+ >
411
+ <ul class="transitions" data-testid="workflow-transitions">
412
+ {#each selected.definition.transitions as transition (transition.id)}
413
+ <li>
414
+ <div class="thead">
415
+ <strong>{transition.name}</strong>
416
+ <span class="path">
417
+ {from(transition)}
418
+ <Icon name="arrow-right" size={12} strokeWidth={1.8} />
419
+ {statusName(transition.to)}
420
+ </span>
421
+ </div>
422
+ <ul class="rules">
423
+ {#if transition.approval}
424
+ <li class="rule approval">
425
+ {describeApprovers(
426
+ transition.approval.approvers,
427
+ transition.approval.minApprovals ?? 1,
428
+ )}
429
+ </li>
430
+ {/if}
431
+ {#each [...(transition.conditions ?? []), ...(transition.validators ?? []), ...(transition.postFunctions ?? [])] as rule, i (rule.type + i)}
432
+ {@const described = describeRule(rule)}
433
+ <li class="rule" class:unknown={described.unknown}>{described.text}</li>
434
+ {/each}
435
+ {#if transition.screen?.fields?.length}
436
+ <li class="rule">
437
+ {t('workflow_asks', { count: transition.screen.fields.length })}
438
+ </li>
439
+ {/if}
440
+ </ul>
441
+ </li>
442
+ {/each}
443
+ </ul>
444
+ </SettingsSection>
445
+ {/if}
446
+ {/if}
447
+ </SettingsPage>
448
+
449
+ <Dialog
450
+ open={newOpen}
451
+ title={t('workflow_new')}
452
+ size="sm"
453
+ onOpenChange={(next: boolean) => {
454
+ if (!next) newOpen = false
455
+ }}
456
+ >
457
+ <div class="ngrid">
458
+ <label class="nrow">
459
+ <span class="lbl">{t('workflow_name')}</span>
460
+ <Input bind:value={newName} placeholder="Support" data-testid="workflow-name" />
461
+ </label>
462
+ <label class="nrow">
463
+ <span class="lbl">{t('workflow_start_from')}</span>
464
+ <Select
465
+ value={newTemplate}
466
+ options={(templatesQuery.data ?? []).map((tpl) => ({ value: tpl.id, label: tpl.name }))}
467
+ onValueChange={(v: string) => (newTemplate = v)}
468
+ />
469
+ <span class="note">{t('workflow_start_from_hint')}</span>
470
+ </label>
471
+ </div>
472
+
473
+ {#snippet footer()}
474
+ <Button variant="ghost" size="sm" onclick={() => (newOpen = false)}>{t('common.cancel')}</Button>
475
+ <Button
476
+ size="sm"
477
+ disabled={!newName.trim()}
478
+ loading={createWorkflow.isPending}
479
+ onclick={() => createWorkflow.mutate()}
480
+ data-testid="workflow-create"
481
+ >
482
+ {t('common.create')}
483
+ </Button>
484
+ {/snippet}
485
+ </Dialog>
486
+
487
+ <style>
488
+ .cat {
489
+ flex: none;
490
+ min-width: 88px;
491
+ }
492
+ .addrow {
493
+ display: grid;
494
+ grid-template-columns: 1fr auto auto;
495
+ align-items: center;
496
+ gap: 8px;
497
+ margin-top: 10px;
498
+ }
499
+ .note {
500
+ margin: 6px 0 0;
501
+ font-size: 12px;
502
+ color: var(--kern-ink-400);
503
+ }
504
+ .problems {
505
+ list-style: none;
506
+ margin: 10px 0 0;
507
+ padding: 8px 10px;
508
+ border: 1px solid var(--kern-danger);
509
+ border-radius: var(--kern-r-sm);
510
+ font-size: 12px;
511
+ color: var(--kern-danger);
512
+ }
513
+ .confirm {
514
+ padding: 6px 0;
515
+ font-size: 12.5px;
516
+ }
517
+ .confirm div {
518
+ display: flex;
519
+ align-items: center;
520
+ gap: 8px;
521
+ }
522
+ .usedby {
523
+ display: grid;
524
+ gap: 6px;
525
+ margin-top: 14px;
526
+ padding-top: 12px;
527
+ border-top: 1px solid var(--kern-border-hairline);
528
+ }
529
+ .lbl {
530
+ margin: 0;
531
+ font-size: 12px;
532
+ font-weight: 500;
533
+ color: var(--kern-ink-600);
534
+ }
535
+ .ngrid {
536
+ display: grid;
537
+ gap: 12px;
538
+ }
539
+ .nrow {
540
+ display: grid;
541
+ gap: 4px;
542
+ }
543
+ .state {
544
+ display: grid;
545
+ place-items: center;
546
+ padding: 26px;
547
+ font-size: 13px;
548
+ }
549
+ .wlist,
550
+ .statuses,
551
+ .transitions,
552
+ .rules {
553
+ list-style: none;
554
+ margin: 0;
555
+ padding: 0;
556
+ }
557
+ .wlist li {
558
+ border-bottom: 1px solid var(--kern-border-hairline);
559
+ }
560
+ .wlist li:last-child {
561
+ border-bottom: 0;
562
+ }
563
+ .wrow {
564
+ display: flex;
565
+ align-items: center;
566
+ gap: 10px;
567
+ width: 100%;
568
+ padding: 10px 18px;
569
+ border: 0;
570
+ background: none;
571
+ color: inherit;
572
+ font: inherit;
573
+ text-align: start;
574
+ cursor: pointer;
575
+ }
576
+ .wrow:hover {
577
+ background: var(--kern-surface-hover);
578
+ }
579
+ .wrow.on {
580
+ background: var(--kern-surface-active);
581
+ }
582
+ .wname {
583
+ font-size: 13px;
584
+ color: var(--kern-ink-800);
585
+ }
586
+ .wmeta {
587
+ flex: 1;
588
+ font-size: 12px;
589
+ color: var(--kern-ink-400);
590
+ }
591
+ .statuses li {
592
+ display: flex;
593
+ align-items: center;
594
+ gap: 8px;
595
+ padding: 3px 0;
596
+ }
597
+ .ord {
598
+ width: 18px;
599
+ font-family: var(--kern-font-mono);
600
+ font-size: 11.5px;
601
+ color: var(--kern-ink-400);
602
+ }
603
+ .sname {
604
+ font-size: 13px;
605
+ }
606
+ .transitions > li {
607
+ padding: 10px 0;
608
+ border-bottom: 1px solid var(--kern-border-hairline);
609
+ }
610
+ .transitions > li:last-child {
611
+ border-bottom: 0;
612
+ }
613
+ .thead {
614
+ display: flex;
615
+ align-items: baseline;
616
+ gap: 10px;
617
+ flex-wrap: wrap;
618
+ font-size: 13px;
619
+ }
620
+ .path {
621
+ display: inline-flex;
622
+ align-items: center;
623
+ gap: 5px;
624
+ font-size: 12px;
625
+ color: var(--kern-ink-450, var(--kern-ink-400));
626
+ }
627
+ .rules {
628
+ margin-top: 4px;
629
+ }
630
+ .rule {
631
+ font-size: 12.5px;
632
+ color: var(--kern-ink-550);
633
+ padding: 1px 0 1px 10px;
634
+ border-inline-start: 2px solid var(--kern-border);
635
+ margin-inline-start: 2px;
636
+ }
637
+ .rule.approval {
638
+ border-color: var(--kern-warning);
639
+ }
640
+ .rule.unknown {
641
+ font-family: var(--kern-font-mono);
642
+ font-size: 11.5px;
643
+ color: var(--kern-ink-400);
644
+ }
645
+ </style>
@@ -0,0 +1,19 @@
1
+ import { toast } from '@kernhq/ui'
2
+ import { createMutation, useQueryClient } from '@tanstack/svelte-query'
3
+
4
+ /**
5
+ * A small mutation that refreshes a list and reports its own failures.
6
+ *
7
+ * The planning page has ten of these and they differ only in the call they make, so the shape lives
8
+ * here rather than ten times. It is a `.ts` file because a `<T,>` generic does not survive the
9
+ * Svelte formatter.
10
+ */
11
+ export function listMutation<T>(fn: (input: T) => Promise<unknown>, refresh: () => void) {
12
+ const queryClient = useQueryClient()
13
+ void queryClient
14
+ return createMutation(() => ({
15
+ mutationFn: fn,
16
+ onSuccess: refresh,
17
+ onError: (error: Error) => toast.error(error.message),
18
+ }))
19
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { parseDuration } from './time.js'
3
+
4
+ describe('parseDuration', () => {
5
+ it('reads a bare number as hours, because that is what people mean', () => {
6
+ expect(parseDuration('2')).toBe(7200)
7
+ expect(parseDuration('0.5')).toBe(1800)
8
+ })
9
+
10
+ it('reads hours and minutes, together or apart', () => {
11
+ expect(parseDuration('2h')).toBe(7200)
12
+ expect(parseDuration('90m')).toBe(5400)
13
+ expect(parseDuration('1h30m')).toBe(5400)
14
+ expect(parseDuration('2h 15m')).toBe(8100)
15
+ })
16
+
17
+ it('does not care about case or spacing', () => {
18
+ expect(parseDuration(' 1H 30M ')).toBe(5400)
19
+ })
20
+
21
+ it('reads the digits a Persian or Arabic keyboard produces', () => {
22
+ // The placeholder says `1h30m` in every locale because the units are not translated — but the
23
+ // digits in front of them are whatever the keyboard makes, and `\d` matches none of those.
24
+ expect(parseDuration('۲')).toBe(7200)
25
+ expect(parseDuration('۱h۳۰m')).toBe(5400)
26
+ expect(parseDuration('٩٠m')).toBe(5400)
27
+ expect(parseDuration('٢h ١٥m')).toBe(8100)
28
+ })
29
+
30
+ it('refuses what is not an amount of time', () => {
31
+ // Silently reading "half an hour" as nothing would log nothing and say it worked.
32
+ expect(parseDuration('half an hour')).toBeNull()
33
+ expect(parseDuration('')).toBeNull()
34
+ expect(parseDuration('0')).toBeNull()
35
+ expect(parseDuration('0h')).toBeNull()
36
+ expect(parseDuration('2 hours')).toBeNull()
37
+ expect(parseDuration('-1h')).toBeNull()
38
+ })
39
+ })