@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,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
|
+
})
|