@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,701 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
coreApi,
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
EmptyState,
|
|
7
|
+
keys,
|
|
8
|
+
type MenuItem,
|
|
9
|
+
navigation,
|
|
10
|
+
PageHeader,
|
|
11
|
+
Skeleton,
|
|
12
|
+
session,
|
|
13
|
+
Tabs,
|
|
14
|
+
Toolbar,
|
|
15
|
+
ToolbarButton,
|
|
16
|
+
toast,
|
|
17
|
+
ViewToggle,
|
|
18
|
+
} from '@kernhq/ui'
|
|
19
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
20
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
21
|
+
import BoardView from '../components/BoardView.svelte'
|
|
22
|
+
import FilterMenu from '../components/FilterMenu.svelte'
|
|
23
|
+
import IssueDetailPanel from '../components/IssueDetailPanel.svelte'
|
|
24
|
+
import IssueListView from '../components/IssueListView.svelte'
|
|
25
|
+
import KqlInput from '../components/KqlInput.svelte'
|
|
26
|
+
import NewIssueDialog from '../components/NewIssueDialog.svelte'
|
|
27
|
+
import NewProjectDialog from '../components/NewProjectDialog.svelte'
|
|
28
|
+
import SaveViewDialog from '../components/SaveViewDialog.svelte'
|
|
29
|
+
import { setTrackerCatalogue, TrackerCatalogue } from '../context.svelte.js'
|
|
30
|
+
import type { CoreApi } from '../core-api.js'
|
|
31
|
+
import {
|
|
32
|
+
composeKql,
|
|
33
|
+
emptyFilters,
|
|
34
|
+
filterCount,
|
|
35
|
+
PRESETS,
|
|
36
|
+
type Preset,
|
|
37
|
+
type TrackerFilters,
|
|
38
|
+
} from '../filters.js'
|
|
39
|
+
import { t } from '../i18n.js'
|
|
40
|
+
import {
|
|
41
|
+
customKqlField,
|
|
42
|
+
type GroupBy,
|
|
43
|
+
groupKeysOf,
|
|
44
|
+
type Issue,
|
|
45
|
+
type KqlField,
|
|
46
|
+
SYSTEM_FIELDS,
|
|
47
|
+
statusStyle,
|
|
48
|
+
} from '../index.js'
|
|
49
|
+
import { estimateLabel, GROUP_CYCLE, groupByLabel, presetLabel } from '../labels.js'
|
|
50
|
+
import { canTracker } from '../permissions.js'
|
|
51
|
+
import { trackerKeys } from '../query.js'
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The issues view: the list and the board over one query.
|
|
55
|
+
*
|
|
56
|
+
* What is being shown lives in the URL — the layout, the grouping, the preset, the query and the
|
|
57
|
+
* open issue — so a view can be linked to, reloaded and shared, and the browser's back button
|
|
58
|
+
* behaves the way people expect when they close a panel.
|
|
59
|
+
*/
|
|
60
|
+
const api = coreApi<CoreApi>()
|
|
61
|
+
const tracker = getTrackerApi()
|
|
62
|
+
const queryClient = useQueryClient()
|
|
63
|
+
|
|
64
|
+
const slug = $derived(navigation.workspaceSlug)
|
|
65
|
+
const workspace = $derived(session.workspaces.find((w) => w.slug === slug))
|
|
66
|
+
const workspaceId = $derived(workspace?.id ?? '')
|
|
67
|
+
|
|
68
|
+
// ---- URL-backed view state ------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
const params = $derived(new URLSearchParams(navigation.search))
|
|
71
|
+
const layout = $derived(params.get('view') === 'board' ? 'board' : 'list')
|
|
72
|
+
const groupBy = $derived(params.get('group') ?? 'status')
|
|
73
|
+
const preset = $derived((params.get('preset') ?? 'all') as Preset)
|
|
74
|
+
const manualKql = $derived(params.get('q') ?? '')
|
|
75
|
+
/**
|
|
76
|
+
* The saved view the page is showing, when it is one this person may change.
|
|
77
|
+
*
|
|
78
|
+
* Without this, refining a saved view and pressing Save quietly produced a second copy of it —
|
|
79
|
+
* the URL knew which view was open and the dialog did not.
|
|
80
|
+
*/
|
|
81
|
+
const viewsQuery = createQuery(() => ({
|
|
82
|
+
queryKey: trackerKeys.views(workspaceId),
|
|
83
|
+
queryFn: () => tracker.views.list({ workspaceId }),
|
|
84
|
+
enabled: Boolean(workspaceId),
|
|
85
|
+
}))
|
|
86
|
+
const canManageShared = $derived(canTracker('viewManageShared'))
|
|
87
|
+
|
|
88
|
+
const editingView = $derived.by(() => {
|
|
89
|
+
const id = params.get('view_id')
|
|
90
|
+
const view = id ? viewsQuery.data?.find((v) => v.id === id) : undefined
|
|
91
|
+
if (!view || view.builtin) return null
|
|
92
|
+
const mine = view.visibility === 'private' ? view.ownerId === session.user?.id : canManageShared
|
|
93
|
+
return mine ? { id: view.id, name: view.name } : null
|
|
94
|
+
})
|
|
95
|
+
const openIssueKey = $derived(params.get('issue'))
|
|
96
|
+
/**
|
|
97
|
+
* What a link may ask this page to open.
|
|
98
|
+
*
|
|
99
|
+
* The palette, the sidebar and any deep link all arrive as a parameter rather than reaching into
|
|
100
|
+
* this component's state — which is also why raising an issue from the sidebar keeps the list that
|
|
101
|
+
* was underneath it, and why the dialog closes by taking the parameter back off the URL.
|
|
102
|
+
*/
|
|
103
|
+
const newRequested = $derived(params.get('new') === '1')
|
|
104
|
+
const newProjectRequested = $derived(params.get('new_project') === '1')
|
|
105
|
+
const saveRequested = $derived(params.get('save') === '1')
|
|
106
|
+
|
|
107
|
+
let newProjectOpen = $state(false)
|
|
108
|
+
let savingView = $state(false)
|
|
109
|
+
/**
|
|
110
|
+
* Creating a project was unreachable: the server has had `projects.create` since the module
|
|
111
|
+
* existed and nothing in the interface called it, so every project came from seed data.
|
|
112
|
+
*/
|
|
113
|
+
const canCreateProject = $derived(canTracker('projectManage'))
|
|
114
|
+
|
|
115
|
+
function setParams(next: Record<string, string | null>) {
|
|
116
|
+
/**
|
|
117
|
+
* Built from `navigation` rather than the router's URL: a module cannot read `$app/state`, and
|
|
118
|
+
* `replaceState` matters here — filtering a list should not fill the back button with every
|
|
119
|
+
* intermediate filter somebody typed through.
|
|
120
|
+
*/
|
|
121
|
+
const params = new URLSearchParams(navigation.search)
|
|
122
|
+
for (const [key, value] of Object.entries(next)) {
|
|
123
|
+
if (value === null || value === '') params.delete(key)
|
|
124
|
+
else params.set(key, value)
|
|
125
|
+
}
|
|
126
|
+
const query = params.toString()
|
|
127
|
+
void navigation.go(`${navigation.pathname}${query ? `?${query}` : ''}`, {
|
|
128
|
+
replaceState: true,
|
|
129
|
+
keepFocus: true,
|
|
130
|
+
noScroll: true,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// what this person may do here; the server checks again, this only keeps the interface honest
|
|
135
|
+
const canCreate = $derived(canTracker('create'))
|
|
136
|
+
const canTransition = $derived(canTracker('transition'))
|
|
137
|
+
const canEdit = $derived(canTracker('edit'))
|
|
138
|
+
const canBulkEdit = $derived(canTracker('bulkEdit'))
|
|
139
|
+
|
|
140
|
+
let filters = $state<TrackerFilters>(emptyFilters())
|
|
141
|
+
let selection = $state<string[]>([])
|
|
142
|
+
let creating = $state(false)
|
|
143
|
+
let createDefaults = $state<{ projectId?: string; statusId?: string }>({})
|
|
144
|
+
let listView = $state<ReturnType<typeof IssueListView> | null>(null)
|
|
145
|
+
|
|
146
|
+
// ---- reference data -------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
const catalogue = new TrackerCatalogue()
|
|
149
|
+
setTrackerCatalogue(catalogue)
|
|
150
|
+
|
|
151
|
+
const enabled = $derived(Boolean(workspaceId))
|
|
152
|
+
|
|
153
|
+
const projectsQuery = createQuery(() => ({
|
|
154
|
+
queryKey: trackerKeys.projects(workspaceId),
|
|
155
|
+
queryFn: () => tracker.projects.list({ workspaceId }),
|
|
156
|
+
enabled,
|
|
157
|
+
}))
|
|
158
|
+
const statusesQuery = createQuery(() => ({
|
|
159
|
+
queryKey: trackerKeys.statuses(workspaceId),
|
|
160
|
+
queryFn: () => tracker.workflows.statuses({ workspaceId }),
|
|
161
|
+
enabled,
|
|
162
|
+
}))
|
|
163
|
+
const typesQuery = createQuery(() => ({
|
|
164
|
+
queryKey: trackerKeys.types(workspaceId),
|
|
165
|
+
queryFn: () => tracker.types.list({ workspaceId }),
|
|
166
|
+
enabled,
|
|
167
|
+
}))
|
|
168
|
+
const labelsQuery = createQuery(() => ({
|
|
169
|
+
queryKey: trackerKeys.labels(workspaceId),
|
|
170
|
+
queryFn: () => tracker.labels.list({ workspaceId }),
|
|
171
|
+
enabled,
|
|
172
|
+
}))
|
|
173
|
+
const fieldsQuery = createQuery(() => ({
|
|
174
|
+
queryKey: trackerKeys.fields(workspaceId),
|
|
175
|
+
queryFn: () => tracker.fields.list({ workspaceId }),
|
|
176
|
+
enabled,
|
|
177
|
+
}))
|
|
178
|
+
const membersQuery = createQuery(() => ({
|
|
179
|
+
queryKey: keys.members(workspaceId),
|
|
180
|
+
queryFn: () => api.workspaces.members.list({ workspaceId }),
|
|
181
|
+
enabled,
|
|
182
|
+
}))
|
|
183
|
+
|
|
184
|
+
const projects = $derived(projectsQuery.data ?? [])
|
|
185
|
+
|
|
186
|
+
// planning data is per project, so ask for every project the workspace has and flatten
|
|
187
|
+
const cyclesQuery = createQuery(() => ({
|
|
188
|
+
queryKey: trackerKeys.cycles(workspaceId, null),
|
|
189
|
+
queryFn: async () =>
|
|
190
|
+
(await Promise.all(projects.map((p) => tracker.cycles.list({ workspaceId, projectId: p.id })))).flat(),
|
|
191
|
+
enabled: enabled && projects.length > 0,
|
|
192
|
+
}))
|
|
193
|
+
const milestonesQuery = createQuery(() => ({
|
|
194
|
+
queryKey: trackerKeys.milestones(workspaceId, null),
|
|
195
|
+
queryFn: async () =>
|
|
196
|
+
(
|
|
197
|
+
await Promise.all(projects.map((p) => tracker.milestones.list({ workspaceId, projectId: p.id })))
|
|
198
|
+
).flat(),
|
|
199
|
+
enabled: enabled && projects.length > 0,
|
|
200
|
+
}))
|
|
201
|
+
const componentsQuery = createQuery(() => ({
|
|
202
|
+
queryKey: trackerKeys.components(workspaceId, null),
|
|
203
|
+
queryFn: async () =>
|
|
204
|
+
(
|
|
205
|
+
await Promise.all(projects.map((p) => tracker.components.list({ workspaceId, projectId: p.id })))
|
|
206
|
+
).flat(),
|
|
207
|
+
enabled: enabled && projects.length > 0,
|
|
208
|
+
}))
|
|
209
|
+
|
|
210
|
+
$effect(() => {
|
|
211
|
+
catalogue.projects = projects
|
|
212
|
+
})
|
|
213
|
+
$effect(() => {
|
|
214
|
+
catalogue.statuses = statusesQuery.data ?? []
|
|
215
|
+
})
|
|
216
|
+
$effect(() => {
|
|
217
|
+
catalogue.types = typesQuery.data ?? []
|
|
218
|
+
})
|
|
219
|
+
$effect(() => {
|
|
220
|
+
catalogue.labels = labelsQuery.data ?? []
|
|
221
|
+
})
|
|
222
|
+
$effect(() => {
|
|
223
|
+
catalogue.components = componentsQuery.data ?? []
|
|
224
|
+
})
|
|
225
|
+
$effect(() => {
|
|
226
|
+
catalogue.fields = fieldsQuery.data ?? []
|
|
227
|
+
})
|
|
228
|
+
$effect(() => {
|
|
229
|
+
catalogue.cycles = cyclesQuery.data ?? []
|
|
230
|
+
})
|
|
231
|
+
$effect(() => {
|
|
232
|
+
catalogue.milestones = milestonesQuery.data ?? []
|
|
233
|
+
})
|
|
234
|
+
$effect(() => {
|
|
235
|
+
// A member with no display name still has to be pickable, so fall back to the address rather
|
|
236
|
+
// than rendering an assignee row with nothing in it.
|
|
237
|
+
catalogue.people = (membersQuery.data?.items ?? []).map((member) => ({
|
|
238
|
+
id: member.user.id,
|
|
239
|
+
name: member.user.name ?? member.user.email,
|
|
240
|
+
avatarUrl: member.user.avatarUrl ?? null,
|
|
241
|
+
}))
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
/** System fields plus this workspace's custom fields, for the query box's validation. */
|
|
245
|
+
const kqlFields = $derived<KqlField[]>([
|
|
246
|
+
...SYSTEM_FIELDS,
|
|
247
|
+
...(fieldsQuery.data ?? []).map((f) => customKqlField(f.key, f.type, f.name)),
|
|
248
|
+
])
|
|
249
|
+
|
|
250
|
+
// ---- the query ------------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
const effectiveKql = $derived(composeKql(preset, filters, manualKql))
|
|
253
|
+
|
|
254
|
+
const issuesQuery = createQuery(() => ({
|
|
255
|
+
queryKey: trackerKeys.issues(workspaceId, `${effectiveKql}|${layout}`),
|
|
256
|
+
queryFn: () =>
|
|
257
|
+
tracker.issues.query({
|
|
258
|
+
workspaceId,
|
|
259
|
+
kql: effectiveKql,
|
|
260
|
+
limit: 500,
|
|
261
|
+
include: { total: true, groupCounts: false, full: true },
|
|
262
|
+
}),
|
|
263
|
+
enabled,
|
|
264
|
+
}))
|
|
265
|
+
|
|
266
|
+
const issues = $derived(issuesQuery.data?.items ?? [])
|
|
267
|
+
const totalPoints = $derived(issues.reduce((sum, issue) => sum + (issue.estimate ?? 0), 0))
|
|
268
|
+
|
|
269
|
+
const activeCycle = $derived(catalogue.activeCycle)
|
|
270
|
+
const cycleProgress = $derived(
|
|
271
|
+
activeCycle && activeCycle.stats.estimateTotal > 0
|
|
272
|
+
? Math.round((activeCycle.stats.estimateDone / activeCycle.stats.estimateTotal) * 100)
|
|
273
|
+
: null,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
const subtitle = $derived(
|
|
277
|
+
[
|
|
278
|
+
activeCycle ? activeCycle.name : presetLabel(preset),
|
|
279
|
+
t('issues_count', { count: issues.length }),
|
|
280
|
+
totalPoints > 0 ? estimateLabel(totalPoints, catalogue.estimateUnit) : null,
|
|
281
|
+
]
|
|
282
|
+
.filter((part): part is string => Boolean(part))
|
|
283
|
+
.join(' · '),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
// ---- mutations ------------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
const moveIssue = createMutation(() => ({
|
|
289
|
+
mutationFn: async (input: {
|
|
290
|
+
issue: Issue
|
|
291
|
+
toColumnId: string
|
|
292
|
+
afterId: string | null
|
|
293
|
+
beforeId: string | null
|
|
294
|
+
}) => {
|
|
295
|
+
// What a drop *means* depends on the grouping: into a status column it is a transition, into a
|
|
296
|
+
// custom field's column it sets that field. The board reports the column and stays out of it.
|
|
297
|
+
if (boardField) {
|
|
298
|
+
const key = boardField.key
|
|
299
|
+
const current = input.issue.custom?.[key]
|
|
300
|
+
const many = boardField.type === 'multiselect' || boardField.type === 'multiuser'
|
|
301
|
+
const next = many
|
|
302
|
+
? [...new Set([...(Array.isArray(current) ? (current as string[]) : []), input.toColumnId])]
|
|
303
|
+
: input.toColumnId
|
|
304
|
+
if (JSON.stringify(current ?? null) !== JSON.stringify(next))
|
|
305
|
+
await tracker.issues.update({
|
|
306
|
+
workspaceId,
|
|
307
|
+
issueId: input.issue.id,
|
|
308
|
+
patch: { custom: { [key]: next } },
|
|
309
|
+
})
|
|
310
|
+
} else if (input.issue.statusId !== input.toColumnId) {
|
|
311
|
+
const toStatusId = input.toColumnId
|
|
312
|
+
const available = await tracker.issues.transitions.available({
|
|
313
|
+
workspaceId,
|
|
314
|
+
issueId: input.issue.id,
|
|
315
|
+
})
|
|
316
|
+
const target = available.find((tr) => tr.toStatusId === toStatusId && tr.allowed)
|
|
317
|
+
if (!target) throw new Error(t('transition_blocked', { status: toStatusId }))
|
|
318
|
+
await tracker.issues.transitions.apply({
|
|
319
|
+
workspaceId,
|
|
320
|
+
issueId: input.issue.id,
|
|
321
|
+
transitionId: target.id,
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
await tracker.issues.rank({
|
|
325
|
+
workspaceId,
|
|
326
|
+
issueId: input.issue.id,
|
|
327
|
+
afterId: input.afterId ?? undefined,
|
|
328
|
+
beforeId: input.beforeId ?? undefined,
|
|
329
|
+
})
|
|
330
|
+
},
|
|
331
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tracker', 'issue'] }),
|
|
332
|
+
onError: (error: Error) => {
|
|
333
|
+
toast.error(error.message)
|
|
334
|
+
void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue'] })
|
|
335
|
+
},
|
|
336
|
+
}))
|
|
337
|
+
|
|
338
|
+
const bulkPriority = createMutation(() => ({
|
|
339
|
+
mutationFn: (priority: Issue['priority']) =>
|
|
340
|
+
tracker.issues.bulkUpdate({ workspaceId, ids: selection, patch: { priority } }),
|
|
341
|
+
onSuccess: () => {
|
|
342
|
+
toast.success(t('bulk_done', { count: selection.length }))
|
|
343
|
+
selection = []
|
|
344
|
+
void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue'] })
|
|
345
|
+
},
|
|
346
|
+
onError: (error: Error) => toast.error(error.message),
|
|
347
|
+
}))
|
|
348
|
+
|
|
349
|
+
// ---- interactions ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
function open(issue: Issue) {
|
|
352
|
+
setParams({ issue: issue.key })
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function toggleSelect(issue: Issue) {
|
|
356
|
+
selection = selection.includes(issue.id)
|
|
357
|
+
? selection.filter((id) => id !== issue.id)
|
|
358
|
+
: [...selection, issue.id]
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function startCreate(defaults: { projectId?: string; statusId?: string } = {}) {
|
|
362
|
+
createDefaults = { projectId: defaults.projectId ?? projects[0]?.id, statusId: defaults.statusId }
|
|
363
|
+
creating = true
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Only fields with a bounded set of values make sensible columns. */
|
|
367
|
+
const GROUPABLE_TYPES = new Set(['select', 'multiselect', 'label', 'user', 'multiuser', 'checkbox'])
|
|
368
|
+
const groupableFields = $derived((fieldsQuery.data ?? []).filter((f) => GROUPABLE_TYPES.has(f.type)))
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* What a board or list can be grouped by: the built-in keys, then the workspace's custom fields.
|
|
372
|
+
*
|
|
373
|
+
* A menu rather than the cycle button this replaced — the set is open-ended now, and cycling
|
|
374
|
+
* through a workspace's twelve fields to reach the thirteenth is not a control.
|
|
375
|
+
*/
|
|
376
|
+
const groupMenu = $derived<MenuItem[]>([
|
|
377
|
+
...GROUP_CYCLE.map((key) => ({
|
|
378
|
+
type: 'checkbox' as const,
|
|
379
|
+
id: key,
|
|
380
|
+
label: groupByLabel(key),
|
|
381
|
+
checked: groupBy === key,
|
|
382
|
+
onCheckedChange: () => setParams({ group: key === 'status' ? null : key }),
|
|
383
|
+
})),
|
|
384
|
+
...(groupableFields.length ? [{ type: 'separator' as const, id: 'custom' } as MenuItem] : []),
|
|
385
|
+
...groupableFields.map((field) => ({
|
|
386
|
+
type: 'checkbox' as const,
|
|
387
|
+
id: `cf.${field.key}`,
|
|
388
|
+
label: field.name,
|
|
389
|
+
checked: groupBy === `cf.${field.key}`,
|
|
390
|
+
onCheckedChange: () => setParams({ group: `cf.${field.key}` }),
|
|
391
|
+
})),
|
|
392
|
+
])
|
|
393
|
+
|
|
394
|
+
/** The custom field the board is grouped by, if any. */
|
|
395
|
+
const boardField = $derived.by(() => {
|
|
396
|
+
const custom = /^cf\.(.+)$/.exec(groupBy)
|
|
397
|
+
return custom ? (catalogue.field(custom[1]) ?? null) : null
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The board's columns. A status board takes the workflow's statuses; a field board takes the
|
|
402
|
+
* field's options, so the columns are exactly the values somebody may choose.
|
|
403
|
+
*/
|
|
404
|
+
const boardColumns = $derived.by(() =>
|
|
405
|
+
boardField
|
|
406
|
+
? boardField.options
|
|
407
|
+
.filter((o) => !o.archived)
|
|
408
|
+
.map((o) => ({ id: o.id, name: o.label, color: o.color ?? 'var(--kern-ink-330)' }))
|
|
409
|
+
: catalogue.statuses.map((status) => ({
|
|
410
|
+
id: status.id,
|
|
411
|
+
name: status.name,
|
|
412
|
+
color: statusStyle(status.category, status.id, status.name).color,
|
|
413
|
+
status,
|
|
414
|
+
})),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
/** The heading for the current grouping, whether it is built in or a field somebody added. */
|
|
418
|
+
const currentGroupLabel = $derived.by(() => {
|
|
419
|
+
const custom = /^cf\.(.+)$/.exec(groupBy)
|
|
420
|
+
if (!custom) return groupByLabel(groupBy as GroupBy)
|
|
421
|
+
return catalogue.field(custom[1])?.name ?? groupBy
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
const isTyping = (target: EventTarget | null) => {
|
|
425
|
+
const el = target as HTMLElement | null
|
|
426
|
+
return Boolean(el && (el.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName ?? '')))
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function onWindowKeydown(event: KeyboardEvent) {
|
|
430
|
+
if (event.metaKey || event.ctrlKey || event.altKey || isTyping(event.target)) return
|
|
431
|
+
if (creating) return
|
|
432
|
+
if (event.key === 'c' && canCreate) {
|
|
433
|
+
event.preventDefault()
|
|
434
|
+
startCreate()
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
if (event.key === 'Escape' && openIssueKey) {
|
|
438
|
+
event.preventDefault()
|
|
439
|
+
setParams({ issue: null })
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
if (layout === 'list' && listView?.handleKey(event)) event.preventDefault()
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const selected = $derived(new Set(selection))
|
|
446
|
+
|
|
447
|
+
// the palette and the sidebar navigate here with a parameter rather than reaching into this state
|
|
448
|
+
$effect(() => {
|
|
449
|
+
if (newRequested && !creating) {
|
|
450
|
+
// `?new=1&project=<id>` raises the issue in the project the link came from
|
|
451
|
+
startCreate({ projectId: params.get('project') ?? undefined })
|
|
452
|
+
setParams({ new: null, project: null })
|
|
453
|
+
}
|
|
454
|
+
})
|
|
455
|
+
$effect(() => {
|
|
456
|
+
if (newProjectRequested && !newProjectOpen) {
|
|
457
|
+
newProjectOpen = true
|
|
458
|
+
setParams({ new_project: null })
|
|
459
|
+
}
|
|
460
|
+
})
|
|
461
|
+
$effect(() => {
|
|
462
|
+
if (saveRequested && !savingView) {
|
|
463
|
+
savingView = true
|
|
464
|
+
setParams({ save: null })
|
|
465
|
+
}
|
|
466
|
+
})
|
|
467
|
+
</script>
|
|
468
|
+
|
|
469
|
+
<svelte:head><title>{t('title')} · Kern</title></svelte:head>
|
|
470
|
+
<svelte:window onkeydown={onWindowKeydown} />
|
|
471
|
+
|
|
472
|
+
<div class="ktracker">
|
|
473
|
+
<PageHeader
|
|
474
|
+
crumbs={[{ label: workspace?.name ?? '' }, { label: t('title') }]}
|
|
475
|
+
title={t('title')}
|
|
476
|
+
{subtitle}
|
|
477
|
+
measure={cycleProgress}
|
|
478
|
+
>
|
|
479
|
+
{#snippet actions()}
|
|
480
|
+
{#if canCreateProject}
|
|
481
|
+
<Button size="sm" variant="secondary" onclick={() => (newProjectOpen = true)} data-testid="new-project">
|
|
482
|
+
{t('project_new')}
|
|
483
|
+
</Button>
|
|
484
|
+
{/if}
|
|
485
|
+
{/snippet}
|
|
486
|
+
</PageHeader>
|
|
487
|
+
|
|
488
|
+
<Toolbar>
|
|
489
|
+
<ViewToggle
|
|
490
|
+
items={[
|
|
491
|
+
{ value: 'list', icon: 'list', label: t('view_list') },
|
|
492
|
+
{ value: 'board', icon: 'columns-3', label: t('view_board') },
|
|
493
|
+
]}
|
|
494
|
+
value={layout}
|
|
495
|
+
onValueChange={(value) => setParams({ view: value === 'board' ? 'board' : null })}
|
|
496
|
+
/>
|
|
497
|
+
<span class="divider"></span>
|
|
498
|
+
|
|
499
|
+
<FilterMenu {filters} onchange={(next) => (filters = next)} />
|
|
500
|
+
|
|
501
|
+
<!-- Both views group now, so the control is not the list's alone. -->
|
|
502
|
+
<DropdownMenu items={groupMenu} align="start">
|
|
503
|
+
{#snippet trigger(props)}
|
|
504
|
+
<ToolbarButton {...props} prefix={t('group_by')} data-testid="group-by">
|
|
505
|
+
{currentGroupLabel}
|
|
506
|
+
</ToolbarButton>
|
|
507
|
+
{/snippet}
|
|
508
|
+
</DropdownMenu>
|
|
509
|
+
|
|
510
|
+
{#if canCreate}
|
|
511
|
+
<ToolbarButton onclick={() => (savingView = true)} data-testid="save-view">
|
|
512
|
+
{t('view_save')}
|
|
513
|
+
</ToolbarButton>
|
|
514
|
+
{/if}
|
|
515
|
+
|
|
516
|
+
{#if filterCount(filters) > 0}
|
|
517
|
+
<ToolbarButton active onClear={() => (filters = emptyFilters())}>
|
|
518
|
+
{t('filter_count', { count: filterCount(filters) })}
|
|
519
|
+
</ToolbarButton>
|
|
520
|
+
{/if}
|
|
521
|
+
|
|
522
|
+
<KqlInput value={manualKql} fields={kqlFields} onapply={(kql) => setParams({ q: kql })} />
|
|
523
|
+
|
|
524
|
+
{#snippet end()}
|
|
525
|
+
<Tabs
|
|
526
|
+
variant="underline"
|
|
527
|
+
items={PRESETS.map((p) => ({ value: p, label: presetLabel(p) }))}
|
|
528
|
+
value={preset}
|
|
529
|
+
onValueChange={(value) => setParams({ preset: value === 'all' ? null : value })}
|
|
530
|
+
label={t('presets')}
|
|
531
|
+
/>
|
|
532
|
+
{/snippet}
|
|
533
|
+
</Toolbar>
|
|
534
|
+
|
|
535
|
+
<div class="body" class:board={layout === 'board'}>
|
|
536
|
+
{#if issuesQuery.isPending}
|
|
537
|
+
<div class="skeleton" aria-busy="true" aria-label={t('common.loading')}>
|
|
538
|
+
{#each [1, 2, 3, 4, 5, 6, 7, 8] as row (row)}
|
|
539
|
+
<div class="srow">
|
|
540
|
+
<Skeleton class="h-[15px] w-[15px] rounded-[4px]" />
|
|
541
|
+
<Skeleton class="h-[11px] w-[52px]" />
|
|
542
|
+
<Skeleton class="h-[15px] w-[15px] rounded-[4px]" />
|
|
543
|
+
<Skeleton class="h-[11px] w-[42%]" />
|
|
544
|
+
</div>
|
|
545
|
+
{/each}
|
|
546
|
+
</div>
|
|
547
|
+
{:else if issuesQuery.isError}
|
|
548
|
+
<div class="state">
|
|
549
|
+
<EmptyState icon="triangle-alert" title={t('error_title')} description={t('error_body')}>
|
|
550
|
+
{#snippet actions()}
|
|
551
|
+
<Button size="sm" variant="secondary" onclick={() => issuesQuery.refetch()}>{t('common.retry')}</Button>
|
|
552
|
+
{/snippet}
|
|
553
|
+
</EmptyState>
|
|
554
|
+
</div>
|
|
555
|
+
{:else if layout === 'board'}
|
|
556
|
+
<BoardView
|
|
557
|
+
{issues}
|
|
558
|
+
columns={boardColumns}
|
|
559
|
+
columnOf={(issue) => groupKeysOf(issue, groupBy).filter((k): k is string => k !== null)}
|
|
560
|
+
wipLimits={boardField ? {} : { in_progress: 5, in_review: 3 }}
|
|
561
|
+
draggable={boardField ? canEdit : canTransition}
|
|
562
|
+
onopen={open}
|
|
563
|
+
onmove={(issue, toColumnId, afterId, beforeId) =>
|
|
564
|
+
moveIssue.mutate({ issue, toColumnId, afterId, beforeId })}
|
|
565
|
+
oncreate={canCreate && !boardField ? (statusId) => startCreate({ statusId }) : undefined}
|
|
566
|
+
/>
|
|
567
|
+
{:else}
|
|
568
|
+
<IssueListView
|
|
569
|
+
bind:this={listView}
|
|
570
|
+
{issues}
|
|
571
|
+
{groupBy}
|
|
572
|
+
selection={selected}
|
|
573
|
+
loading={issuesQuery.isFetching}
|
|
574
|
+
onopen={open}
|
|
575
|
+
onselect={toggleSelect}
|
|
576
|
+
oncreate={canCreate ? () => startCreate() : undefined}
|
|
577
|
+
/>
|
|
578
|
+
{/if}
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
{#if selection.length > 0}
|
|
582
|
+
<div class="bulk" role="status">
|
|
583
|
+
<span>{t('selected', { count: selection.length })}</span>
|
|
584
|
+
{#if canBulkEdit}
|
|
585
|
+
<span class="bdiv"></span>
|
|
586
|
+
<button type="button" onclick={() => bulkPriority.mutate('urgent')}>
|
|
587
|
+
{t('bulk_urgent')}
|
|
588
|
+
</button>
|
|
589
|
+
{/if}
|
|
590
|
+
<span class="bdiv"></span>
|
|
591
|
+
<button type="button" onclick={() => (selection = [])}>{t('common.close')}</button>
|
|
592
|
+
</div>
|
|
593
|
+
{/if}
|
|
594
|
+
|
|
595
|
+
<SaveViewDialog
|
|
596
|
+
open={savingView}
|
|
597
|
+
{workspaceId}
|
|
598
|
+
kql={manualKql}
|
|
599
|
+
layout={layout === 'board' ? 'board' : 'list'}
|
|
600
|
+
{groupBy}
|
|
601
|
+
editing={editingView}
|
|
602
|
+
onclose={() => (savingView = false)}
|
|
603
|
+
/>
|
|
604
|
+
|
|
605
|
+
<NewProjectDialog
|
|
606
|
+
open={newProjectOpen}
|
|
607
|
+
{workspaceId}
|
|
608
|
+
onclose={() => (newProjectOpen = false)}
|
|
609
|
+
oncreated={(key) => toast.success(t('project_created', { key }))}
|
|
610
|
+
/>
|
|
611
|
+
|
|
612
|
+
<IssueDetailPanel
|
|
613
|
+
{workspaceId}
|
|
614
|
+
issueKey={openIssueKey}
|
|
615
|
+
onclose={() => setParams({ issue: null })}
|
|
616
|
+
onopenissue={(key) => setParams({ issue: key })}
|
|
617
|
+
/>
|
|
618
|
+
</div>
|
|
619
|
+
|
|
620
|
+
<NewIssueDialog
|
|
621
|
+
bind:open={creating}
|
|
622
|
+
{workspaceId}
|
|
623
|
+
defaults={createDefaults}
|
|
624
|
+
oncreated={(issue) => setParams({ issue: issue.key })}
|
|
625
|
+
/>
|
|
626
|
+
|
|
627
|
+
<style>
|
|
628
|
+
.ktracker {
|
|
629
|
+
position: relative;
|
|
630
|
+
display: flex;
|
|
631
|
+
flex-direction: column;
|
|
632
|
+
flex: 1;
|
|
633
|
+
min-height: 0;
|
|
634
|
+
overflow: hidden;
|
|
635
|
+
background: var(--kern-surface);
|
|
636
|
+
}
|
|
637
|
+
.divider {
|
|
638
|
+
width: 1px;
|
|
639
|
+
height: 18px;
|
|
640
|
+
margin: 0 4px;
|
|
641
|
+
background: var(--kern-border);
|
|
642
|
+
flex: none;
|
|
643
|
+
align-self: center;
|
|
644
|
+
}
|
|
645
|
+
.body {
|
|
646
|
+
flex: 1;
|
|
647
|
+
min-height: 0;
|
|
648
|
+
overflow-y: auto;
|
|
649
|
+
overflow-x: hidden;
|
|
650
|
+
}
|
|
651
|
+
/* the board is its own scroller in both directions, so the page must not add a second one */
|
|
652
|
+
.body.board {
|
|
653
|
+
overflow: hidden;
|
|
654
|
+
}
|
|
655
|
+
.skeleton {
|
|
656
|
+
display: flex;
|
|
657
|
+
flex-direction: column;
|
|
658
|
+
}
|
|
659
|
+
.srow {
|
|
660
|
+
display: flex;
|
|
661
|
+
align-items: center;
|
|
662
|
+
gap: 10px;
|
|
663
|
+
height: 40px;
|
|
664
|
+
padding: 0 16px;
|
|
665
|
+
border-bottom: 1px solid var(--kern-border-hairline);
|
|
666
|
+
}
|
|
667
|
+
.state {
|
|
668
|
+
padding: 40px 28px;
|
|
669
|
+
}
|
|
670
|
+
.bulk {
|
|
671
|
+
position: absolute;
|
|
672
|
+
bottom: 20px;
|
|
673
|
+
inset-inline-start: 50%;
|
|
674
|
+
transform: translateX(-50%);
|
|
675
|
+
display: flex;
|
|
676
|
+
align-items: center;
|
|
677
|
+
gap: 14px;
|
|
678
|
+
padding: 10px 16px;
|
|
679
|
+
border-radius: var(--kern-r-lg);
|
|
680
|
+
background: var(--kern-ink-900);
|
|
681
|
+
color: var(--kern-ink-inverse);
|
|
682
|
+
font-size: 13px;
|
|
683
|
+
box-shadow: var(--kern-shadow-toast);
|
|
684
|
+
z-index: 30;
|
|
685
|
+
animation: kfade 0.14s ease-out;
|
|
686
|
+
}
|
|
687
|
+
:global([dir='rtl']) .bulk {
|
|
688
|
+
transform: translateX(50%);
|
|
689
|
+
}
|
|
690
|
+
.bulk button {
|
|
691
|
+
color: inherit;
|
|
692
|
+
}
|
|
693
|
+
.bulk button:hover {
|
|
694
|
+
opacity: 0.75;
|
|
695
|
+
}
|
|
696
|
+
.bdiv {
|
|
697
|
+
width: 1px;
|
|
698
|
+
height: 16px;
|
|
699
|
+
background: rgb(255 255 255 / 0.2);
|
|
700
|
+
}
|
|
701
|
+
</style>
|