@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,210 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Dialog, DropdownMenu, type MenuItem, Select, toast } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import { untrack } from 'svelte'
|
|
5
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
6
|
+
import { getTrackerCatalogue } from '../context.svelte.js'
|
|
7
|
+
import { t } from '../i18n.js'
|
|
8
|
+
import type { Issue, Priority } from '../index.js'
|
|
9
|
+
import { PRIORITY_GROUP_ORDER } from '../index.js'
|
|
10
|
+
import { priorityLabel } from '../labels.js'
|
|
11
|
+
import { docFromText } from '../richtext.js'
|
|
12
|
+
import PriorityGlyph from './PriorityGlyph.svelte'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Creating an issue, keyboard first.
|
|
16
|
+
*
|
|
17
|
+
* `c` opens it from anywhere in the tracker, the title has focus immediately, and Cmd/Ctrl+Enter
|
|
18
|
+
* submits without reaching for the mouse. Everything except the title is optional and prefilled from
|
|
19
|
+
* wherever you pressed `c` — the project you are looking at, the column you clicked `+` on.
|
|
20
|
+
*/
|
|
21
|
+
interface Props {
|
|
22
|
+
open: boolean
|
|
23
|
+
workspaceId: string
|
|
24
|
+
/** prefilled from the caller: the project in view and the group the "+" belonged to */
|
|
25
|
+
defaults?: { projectId?: string; statusId?: string; priority?: Priority; assigneeIds?: string[] }
|
|
26
|
+
oncreated?: (issue: Issue) => void
|
|
27
|
+
}
|
|
28
|
+
let { open = $bindable(false), workspaceId, defaults = {}, oncreated }: Props = $props()
|
|
29
|
+
|
|
30
|
+
const api = getTrackerApi()
|
|
31
|
+
const cat = getTrackerCatalogue()
|
|
32
|
+
const queryClient = useQueryClient()
|
|
33
|
+
|
|
34
|
+
let title = $state('')
|
|
35
|
+
let description = $state('')
|
|
36
|
+
let projectId = $state('')
|
|
37
|
+
let typeId = $state('')
|
|
38
|
+
let priority = $state<Priority>('none')
|
|
39
|
+
let titleEl = $state<HTMLInputElement | null>(null)
|
|
40
|
+
|
|
41
|
+
// Reset when the dialog opens, and only then: the defaults come from queries that may still be
|
|
42
|
+
// settling, and re-running this while someone is typing would wipe what they had written.
|
|
43
|
+
$effect(() => {
|
|
44
|
+
if (!open) return
|
|
45
|
+
untrack(() => {
|
|
46
|
+
title = ''
|
|
47
|
+
description = ''
|
|
48
|
+
priority = defaults.priority ?? 'none'
|
|
49
|
+
projectId = defaults.projectId ?? cat.projects[0]?.id ?? ''
|
|
50
|
+
typeId = cat.types.find((type) => type.isDefault)?.id ?? cat.types[0]?.id ?? ''
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const create = createMutation(() => ({
|
|
55
|
+
mutationFn: () =>
|
|
56
|
+
api.issues.create({
|
|
57
|
+
workspaceId,
|
|
58
|
+
projectId,
|
|
59
|
+
typeId: typeId || undefined,
|
|
60
|
+
title: title.trim(),
|
|
61
|
+
description: docFromText(description),
|
|
62
|
+
priority,
|
|
63
|
+
statusId: defaults.statusId,
|
|
64
|
+
assigneeIds: defaults.assigneeIds,
|
|
65
|
+
}),
|
|
66
|
+
onSuccess: (issue) => {
|
|
67
|
+
toast.success(t('created', { key: issue.key }))
|
|
68
|
+
void queryClient.invalidateQueries({ queryKey: ['tracker', 'issue'] })
|
|
69
|
+
open = false
|
|
70
|
+
oncreated?.(issue)
|
|
71
|
+
},
|
|
72
|
+
onError: (error: Error) => toast.error(error.message),
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
const canSubmit = $derived(title.trim().length > 0 && projectId !== '')
|
|
76
|
+
|
|
77
|
+
function submit() {
|
|
78
|
+
if (canSubmit && !create.isPending) create.mutate()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const priorityMenu = $derived<MenuItem[]>(
|
|
82
|
+
PRIORITY_GROUP_ORDER.map((p) => ({
|
|
83
|
+
id: p,
|
|
84
|
+
label: priorityLabel(p),
|
|
85
|
+
icon: priority === p ? 'check' : undefined,
|
|
86
|
+
onSelect: () => (priority = p),
|
|
87
|
+
})),
|
|
88
|
+
)
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<Dialog bind:open title={t('new_issue')} size="lg" initialFocus={() => titleEl}>
|
|
92
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
93
|
+
<form
|
|
94
|
+
onsubmit={(e) => {
|
|
95
|
+
e.preventDefault()
|
|
96
|
+
submit()
|
|
97
|
+
}}
|
|
98
|
+
onkeydown={(e) => {
|
|
99
|
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
|
100
|
+
e.preventDefault()
|
|
101
|
+
submit()
|
|
102
|
+
}
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<input
|
|
106
|
+
bind:this={titleEl}
|
|
107
|
+
bind:value={title}
|
|
108
|
+
class="title"
|
|
109
|
+
placeholder={t('title_placeholder')}
|
|
110
|
+
aria-label={t('field_title')}
|
|
111
|
+
data-testid="new-issue-title"
|
|
112
|
+
/>
|
|
113
|
+
<textarea
|
|
114
|
+
bind:value={description}
|
|
115
|
+
class="body"
|
|
116
|
+
rows="4"
|
|
117
|
+
placeholder={t('description_placeholder')}
|
|
118
|
+
aria-label={t('field_description')}
|
|
119
|
+
></textarea>
|
|
120
|
+
|
|
121
|
+
<div class="controls">
|
|
122
|
+
<Select
|
|
123
|
+
bind:value={projectId}
|
|
124
|
+
options={cat.projects.map((p) => ({ value: p.id, label: p.name }))}
|
|
125
|
+
placeholder={t('field_project')}
|
|
126
|
+
size="sm"
|
|
127
|
+
/>
|
|
128
|
+
<Select
|
|
129
|
+
bind:value={typeId}
|
|
130
|
+
options={cat.types.map((type) => ({ value: type.id, label: type.name }))}
|
|
131
|
+
placeholder={t('field_type')}
|
|
132
|
+
size="sm"
|
|
133
|
+
/>
|
|
134
|
+
<DropdownMenu items={priorityMenu} align="start">
|
|
135
|
+
{#snippet trigger(props)}
|
|
136
|
+
<button {...props} type="button" class="pri">
|
|
137
|
+
<PriorityGlyph {priority} size={15} />
|
|
138
|
+
{priorityLabel(priority)}
|
|
139
|
+
</button>
|
|
140
|
+
{/snippet}
|
|
141
|
+
</DropdownMenu>
|
|
142
|
+
</div>
|
|
143
|
+
</form>
|
|
144
|
+
|
|
145
|
+
{#snippet footer()}
|
|
146
|
+
<span class="hint">{t('submit_hint')}</span>
|
|
147
|
+
<Button variant="ghost" size="sm" onclick={() => (open = false)}>{t('common.cancel')}</Button>
|
|
148
|
+
<Button size="sm" disabled={!canSubmit} loading={create.isPending} onclick={submit}>
|
|
149
|
+
{t('common.create')}
|
|
150
|
+
</Button>
|
|
151
|
+
{/snippet}
|
|
152
|
+
</Dialog>
|
|
153
|
+
|
|
154
|
+
<style>
|
|
155
|
+
.title {
|
|
156
|
+
width: 100%;
|
|
157
|
+
font-size: 17px;
|
|
158
|
+
font-weight: 600;
|
|
159
|
+
letter-spacing: -0.015em;
|
|
160
|
+
color: var(--kern-ink-900);
|
|
161
|
+
background: transparent;
|
|
162
|
+
border: 0;
|
|
163
|
+
padding: 0 0 8px;
|
|
164
|
+
}
|
|
165
|
+
.title:focus-visible {
|
|
166
|
+
box-shadow: none;
|
|
167
|
+
}
|
|
168
|
+
.body {
|
|
169
|
+
width: 100%;
|
|
170
|
+
border: 0;
|
|
171
|
+
background: transparent;
|
|
172
|
+
resize: vertical;
|
|
173
|
+
font-size: 13.5px;
|
|
174
|
+
line-height: 1.6;
|
|
175
|
+
color: var(--kern-ink-700);
|
|
176
|
+
padding: 0;
|
|
177
|
+
}
|
|
178
|
+
.body:focus-visible {
|
|
179
|
+
box-shadow: none;
|
|
180
|
+
}
|
|
181
|
+
.controls {
|
|
182
|
+
display: flex;
|
|
183
|
+
flex-wrap: wrap;
|
|
184
|
+
gap: 8px;
|
|
185
|
+
margin-top: 16px;
|
|
186
|
+
padding-top: 14px;
|
|
187
|
+
border-top: 1px solid var(--kern-border-hairline);
|
|
188
|
+
}
|
|
189
|
+
.pri {
|
|
190
|
+
display: inline-flex;
|
|
191
|
+
align-items: center;
|
|
192
|
+
gap: 7px;
|
|
193
|
+
height: 30px;
|
|
194
|
+
padding: 0 11px;
|
|
195
|
+
border: 1px solid var(--kern-border-strong);
|
|
196
|
+
border-radius: var(--kern-r-md);
|
|
197
|
+
background: var(--kern-btn-secondary-bg);
|
|
198
|
+
color: var(--kern-ink-650);
|
|
199
|
+
font-size: 13px;
|
|
200
|
+
}
|
|
201
|
+
.pri:hover {
|
|
202
|
+
background: var(--kern-btn-secondary-hover);
|
|
203
|
+
}
|
|
204
|
+
.hint {
|
|
205
|
+
margin-inline-end: auto;
|
|
206
|
+
font-family: var(--kern-font-mono);
|
|
207
|
+
font-size: 11.5px;
|
|
208
|
+
color: var(--kern-ink-250);
|
|
209
|
+
}
|
|
210
|
+
</style>
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Dialog, Icon, Input, Textarea, toast } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import type { ProjectTemplate } from '../index.js'
|
|
7
|
+
import { trackerKeys } from '../query.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Making a project, and choosing the shape it starts in.
|
|
11
|
+
*
|
|
12
|
+
* The template is the only decision here that is hard to change later: it seeds the work item
|
|
13
|
+
* types, the fields and the layouts, and those become what everyone on the project sees. So it is
|
|
14
|
+
* the part of this dialog that gets the room, and each option says what it is for rather than what
|
|
15
|
+
* it contains.
|
|
16
|
+
*/
|
|
17
|
+
interface Props {
|
|
18
|
+
open: boolean
|
|
19
|
+
workspaceId: string
|
|
20
|
+
onclose: () => void
|
|
21
|
+
oncreated: (key: string) => void
|
|
22
|
+
}
|
|
23
|
+
let { open, workspaceId, onclose, oncreated }: Props = $props()
|
|
24
|
+
|
|
25
|
+
const api = getTrackerApi()
|
|
26
|
+
const queryClient = useQueryClient()
|
|
27
|
+
|
|
28
|
+
let name = $state('')
|
|
29
|
+
let key = $state('')
|
|
30
|
+
let description = $state('')
|
|
31
|
+
let templateId = $state('software')
|
|
32
|
+
let keyEdited = $state(false)
|
|
33
|
+
|
|
34
|
+
const templatesQuery = createQuery(() => ({
|
|
35
|
+
queryKey: [...trackerKeys.projects(workspaceId), 'templates'],
|
|
36
|
+
queryFn: () => api.projects.templates.list({ workspaceId }),
|
|
37
|
+
enabled: open && Boolean(workspaceId),
|
|
38
|
+
}))
|
|
39
|
+
const templates = $derived(templatesQuery.data ?? [])
|
|
40
|
+
|
|
41
|
+
$effect(() => {
|
|
42
|
+
if (!open) return
|
|
43
|
+
name = ''
|
|
44
|
+
key = ''
|
|
45
|
+
description = ''
|
|
46
|
+
templateId = 'software'
|
|
47
|
+
keyEdited = false
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/** `Kern Platform` → `KERN`: initials for several words, the first letters for one. */
|
|
51
|
+
const suggestKey = (value: string) => {
|
|
52
|
+
const words = value.trim().split(/\s+/).filter(Boolean)
|
|
53
|
+
const raw = words.length > 1 ? words.map((w) => w[0] ?? '').join('') : (words[0] ?? '').slice(0, 4)
|
|
54
|
+
return raw
|
|
55
|
+
.toUpperCase()
|
|
56
|
+
.replace(/[^A-Z0-9]/g, '')
|
|
57
|
+
.slice(0, 10)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
$effect(() => {
|
|
61
|
+
if (!keyEdited) key = suggestKey(name)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const keyValid = $derived(/^[A-Z][A-Z0-9]{1,9}$/.test(key))
|
|
65
|
+
const valid = $derived(Boolean(name.trim()) && keyValid)
|
|
66
|
+
|
|
67
|
+
const create = createMutation(() => ({
|
|
68
|
+
mutationFn: () => {
|
|
69
|
+
const chosen = templates.find((tpl) => tpl.id === templateId)
|
|
70
|
+
return api.projects.create({
|
|
71
|
+
workspaceId,
|
|
72
|
+
name: name.trim(),
|
|
73
|
+
key,
|
|
74
|
+
description: description.trim() || null,
|
|
75
|
+
// A built-in is named; anything the workspace saved is referenced by id.
|
|
76
|
+
...(chosen?.builtin === false ? { templateId } : { template: templateId }),
|
|
77
|
+
} as never)
|
|
78
|
+
},
|
|
79
|
+
onSuccess: (project: { key: string }) => {
|
|
80
|
+
void queryClient.invalidateQueries({ queryKey: trackerKeys.projects(workspaceId) })
|
|
81
|
+
oncreated(project.key)
|
|
82
|
+
onclose()
|
|
83
|
+
},
|
|
84
|
+
onError: (error: Error) => toast.error(error.message),
|
|
85
|
+
}))
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<Dialog
|
|
89
|
+
{open}
|
|
90
|
+
title={t('project_new')}
|
|
91
|
+
size="lg"
|
|
92
|
+
onOpenChange={(next: boolean) => {
|
|
93
|
+
if (!next) onclose()
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<div class="grid gap-3">
|
|
97
|
+
<div class="two">
|
|
98
|
+
<label class="grid gap-1">
|
|
99
|
+
<span class="lbl">{t('project_name')}</span>
|
|
100
|
+
<Input bind:value={name} placeholder="Kern Platform" data-testid="project-name" />
|
|
101
|
+
</label>
|
|
102
|
+
<label class="grid gap-1">
|
|
103
|
+
<span class="lbl">{t('project_key')}</span>
|
|
104
|
+
<Input
|
|
105
|
+
bind:value={key}
|
|
106
|
+
oninput={() => (keyEdited = true)}
|
|
107
|
+
placeholder="KERN"
|
|
108
|
+
data-testid="project-key"
|
|
109
|
+
/>
|
|
110
|
+
<span class="hint" class:bad={key.length > 0 && !keyValid}>{t('project_key_hint')}</span>
|
|
111
|
+
</label>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<label class="grid gap-1">
|
|
115
|
+
<span class="lbl">{t('project_description')}</span>
|
|
116
|
+
<Textarea bind:value={description} rows={2} />
|
|
117
|
+
</label>
|
|
118
|
+
|
|
119
|
+
<fieldset class="grid gap-1">
|
|
120
|
+
<legend class="lbl">{t('project_template')}</legend>
|
|
121
|
+
<span class="hint">{t('project_template_hint')}</span>
|
|
122
|
+
<ul class="templates" data-testid="template-choices">
|
|
123
|
+
{#each templates as template (template.id)}
|
|
124
|
+
<li>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
class="tpl"
|
|
128
|
+
class:on={templateId === template.id}
|
|
129
|
+
aria-pressed={templateId === template.id}
|
|
130
|
+
onclick={() => (templateId = template.id)}
|
|
131
|
+
data-template={template.key}
|
|
132
|
+
>
|
|
133
|
+
<span class="tname">
|
|
134
|
+
{template.name}
|
|
135
|
+
{#if templateId === template.id}<Icon name="check" size={13} strokeWidth={2} />{/if}
|
|
136
|
+
</span>
|
|
137
|
+
{#if template.description}<span class="tdesc">{template.description}</span>{/if}
|
|
138
|
+
</button>
|
|
139
|
+
</li>
|
|
140
|
+
{/each}
|
|
141
|
+
</ul>
|
|
142
|
+
</fieldset>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{#snippet footer()}
|
|
146
|
+
<Button variant="ghost" size="sm" onclick={onclose}>{t('common.cancel')}</Button>
|
|
147
|
+
<Button
|
|
148
|
+
size="sm"
|
|
149
|
+
disabled={!valid}
|
|
150
|
+
loading={create.isPending}
|
|
151
|
+
onclick={() => create.mutate()}
|
|
152
|
+
data-testid="project-create"
|
|
153
|
+
>
|
|
154
|
+
{t('project_create')}
|
|
155
|
+
</Button>
|
|
156
|
+
{/snippet}
|
|
157
|
+
</Dialog>
|
|
158
|
+
|
|
159
|
+
<style>
|
|
160
|
+
.lbl {
|
|
161
|
+
font-size: 12px;
|
|
162
|
+
color: var(--kern-ink-550);
|
|
163
|
+
}
|
|
164
|
+
.hint {
|
|
165
|
+
display: block;
|
|
166
|
+
font-size: 11.5px;
|
|
167
|
+
color: var(--kern-ink-400);
|
|
168
|
+
}
|
|
169
|
+
.hint.bad {
|
|
170
|
+
color: var(--kern-danger);
|
|
171
|
+
}
|
|
172
|
+
.two {
|
|
173
|
+
display: grid;
|
|
174
|
+
grid-template-columns: 2fr 1fr;
|
|
175
|
+
gap: 10px;
|
|
176
|
+
}
|
|
177
|
+
fieldset {
|
|
178
|
+
border: 0;
|
|
179
|
+
padding: 0;
|
|
180
|
+
margin: 0;
|
|
181
|
+
}
|
|
182
|
+
.templates {
|
|
183
|
+
display: grid;
|
|
184
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
185
|
+
/* every card is the same size whatever its description costs in lines: `1fr` rows make each row
|
|
186
|
+
as tall as the tallest card in the grid, and the `li` stretches its button to fill the cell —
|
|
187
|
+
without it a two-line description left the card beside it visibly short. */
|
|
188
|
+
grid-auto-rows: 1fr;
|
|
189
|
+
gap: 8px;
|
|
190
|
+
margin: 6px 0 0;
|
|
191
|
+
padding: 0;
|
|
192
|
+
list-style: none;
|
|
193
|
+
}
|
|
194
|
+
.templates > li {
|
|
195
|
+
display: grid;
|
|
196
|
+
}
|
|
197
|
+
@media (max-width: 560px) {
|
|
198
|
+
.templates {
|
|
199
|
+
grid-template-columns: 1fr;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
.tpl {
|
|
203
|
+
display: flex;
|
|
204
|
+
flex-direction: column;
|
|
205
|
+
gap: 2px;
|
|
206
|
+
width: 100%;
|
|
207
|
+
padding: 10px 12px;
|
|
208
|
+
border: 1px solid var(--kern-border);
|
|
209
|
+
border-radius: var(--kern-r-sm);
|
|
210
|
+
background: var(--kern-surface);
|
|
211
|
+
color: inherit;
|
|
212
|
+
font: inherit;
|
|
213
|
+
text-align: start;
|
|
214
|
+
cursor: pointer;
|
|
215
|
+
}
|
|
216
|
+
.tpl:hover {
|
|
217
|
+
background: var(--kern-surface-hover);
|
|
218
|
+
}
|
|
219
|
+
.tpl.on {
|
|
220
|
+
border-color: var(--kern-accent);
|
|
221
|
+
background: var(--kern-info-tint);
|
|
222
|
+
}
|
|
223
|
+
.tname {
|
|
224
|
+
display: flex;
|
|
225
|
+
align-items: center;
|
|
226
|
+
justify-content: space-between;
|
|
227
|
+
gap: 6px;
|
|
228
|
+
font-size: 13px;
|
|
229
|
+
font-weight: 600;
|
|
230
|
+
}
|
|
231
|
+
.tdesc {
|
|
232
|
+
font-size: 12px;
|
|
233
|
+
color: var(--kern-ink-450, var(--kern-ink-400));
|
|
234
|
+
}
|
|
235
|
+
</style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { PRIORITY_BAR_GEOMETRY, type Priority, priorityBars } from '../index.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The priority glyph (DESIGN.md 3.0): three rising bars, lit from the left. Urgent inverts into a
|
|
6
|
+
* filled red box so it reads at a glance in a dense list; everything else is drawn on the paper.
|
|
7
|
+
*/
|
|
8
|
+
interface Props {
|
|
9
|
+
priority: Priority
|
|
10
|
+
size?: number
|
|
11
|
+
label?: string | null
|
|
12
|
+
}
|
|
13
|
+
let { priority, size = 16, label = null }: Props = $props()
|
|
14
|
+
const bars = $derived(priorityBars(priority))
|
|
15
|
+
const urgent = $derived(priority === 'urgent')
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<svg
|
|
19
|
+
width={size}
|
|
20
|
+
height={size}
|
|
21
|
+
viewBox="0 0 16 16"
|
|
22
|
+
fill="none"
|
|
23
|
+
class="kpg"
|
|
24
|
+
role={label ? 'img' : 'presentation'}
|
|
25
|
+
aria-label={label ?? undefined}
|
|
26
|
+
aria-hidden={label ? undefined : 'true'}
|
|
27
|
+
>
|
|
28
|
+
{#if urgent}
|
|
29
|
+
<rect x="0" y="0" width="16" height="16" rx="4" fill="var(--kern-danger)" />
|
|
30
|
+
{/if}
|
|
31
|
+
{#each PRIORITY_BAR_GEOMETRY as bar, i (bar.x)}
|
|
32
|
+
<rect
|
|
33
|
+
x={bar.x}
|
|
34
|
+
y={bar.y}
|
|
35
|
+
width="3"
|
|
36
|
+
height={bar.height}
|
|
37
|
+
rx="1"
|
|
38
|
+
fill={urgent
|
|
39
|
+
? 'var(--kern-ink-inverse)'
|
|
40
|
+
: bars[i]
|
|
41
|
+
? 'var(--kern-ink-580)'
|
|
42
|
+
: 'var(--kern-priority-off)'}
|
|
43
|
+
/>
|
|
44
|
+
{/each}
|
|
45
|
+
</svg>
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
.kpg {
|
|
49
|
+
flex: none;
|
|
50
|
+
display: block;
|
|
51
|
+
}
|
|
52
|
+
</style>
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Dialog, Input, Select, toast } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import { canTracker } from '../permissions.js'
|
|
7
|
+
import { trackerKeys } from '../query.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Naming the query you are looking at.
|
|
11
|
+
*
|
|
12
|
+
* The view captures what the page is showing — the KQL, the layout and the grouping — because that
|
|
13
|
+
* is what somebody means by "this view". Anything they change afterwards is a different view until
|
|
14
|
+
* they save it again.
|
|
15
|
+
*
|
|
16
|
+
* When the page is already showing a saved view, saving offers to change that view as well as to
|
|
17
|
+
* make a new one. Without the first, refining a view you had saved silently produced a second copy
|
|
18
|
+
* of it, and a sidebar filled with "My bugs 2".
|
|
19
|
+
*/
|
|
20
|
+
interface Props {
|
|
21
|
+
open: boolean
|
|
22
|
+
workspaceId: string
|
|
23
|
+
kql: string
|
|
24
|
+
layout: 'list' | 'board'
|
|
25
|
+
groupBy: string
|
|
26
|
+
/** the saved view the page is showing, when it is showing one you may change */
|
|
27
|
+
editing?: { id: string; name: string } | null
|
|
28
|
+
onclose: () => void
|
|
29
|
+
}
|
|
30
|
+
let { open, workspaceId, kql, layout, groupBy, editing = null, onclose }: Props = $props()
|
|
31
|
+
|
|
32
|
+
const api = getTrackerApi()
|
|
33
|
+
const queryClient = useQueryClient()
|
|
34
|
+
|
|
35
|
+
let name = $state('')
|
|
36
|
+
let visibility = $state<'private' | 'project' | 'workspace'>('private')
|
|
37
|
+
|
|
38
|
+
$effect(() => {
|
|
39
|
+
if (!open) return
|
|
40
|
+
name = ''
|
|
41
|
+
visibility = 'private'
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/** Sharing a view with other people is a permission; keeping one for yourself is not. */
|
|
45
|
+
const canShare = $derived(canTracker('viewManageShared'))
|
|
46
|
+
const options = $derived([
|
|
47
|
+
{ value: 'private', label: t('view_private') },
|
|
48
|
+
...(canShare
|
|
49
|
+
? [
|
|
50
|
+
{ value: 'project', label: t('view_project') },
|
|
51
|
+
{ value: 'workspace', label: t('view_workspace') },
|
|
52
|
+
]
|
|
53
|
+
: []),
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
const done = (view: { name: string }) => {
|
|
57
|
+
void queryClient.invalidateQueries({ queryKey: trackerKeys.views(workspaceId) })
|
|
58
|
+
toast.success(t('view_saved', { name: view.name }))
|
|
59
|
+
onclose()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Changing the view that is open: the same query the page is showing, under the same name. */
|
|
63
|
+
const update = createMutation(() => ({
|
|
64
|
+
mutationFn: () =>
|
|
65
|
+
api.views.update({
|
|
66
|
+
workspaceId,
|
|
67
|
+
id: editing?.id as string,
|
|
68
|
+
patch: { kql, layout, display: { groupBy } },
|
|
69
|
+
} as never),
|
|
70
|
+
onSuccess: done,
|
|
71
|
+
onError: (error: Error) => toast.error(error.message),
|
|
72
|
+
}))
|
|
73
|
+
|
|
74
|
+
const save = createMutation(() => ({
|
|
75
|
+
mutationFn: () =>
|
|
76
|
+
api.views.create({
|
|
77
|
+
workspaceId,
|
|
78
|
+
name: name.trim(),
|
|
79
|
+
kql,
|
|
80
|
+
layout,
|
|
81
|
+
display: { groupBy },
|
|
82
|
+
visibility,
|
|
83
|
+
} as never),
|
|
84
|
+
onSuccess: done,
|
|
85
|
+
onError: (error: Error) => toast.error(error.message),
|
|
86
|
+
}))
|
|
87
|
+
</script>
|
|
88
|
+
|
|
89
|
+
<Dialog
|
|
90
|
+
{open}
|
|
91
|
+
title={editing ? t('view_save_changes') : t('view_save')}
|
|
92
|
+
size="sm"
|
|
93
|
+
onOpenChange={(next: boolean) => {
|
|
94
|
+
if (!next) onclose()
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<div class="grid gap-3">
|
|
98
|
+
<label class="grid gap-1">
|
|
99
|
+
<span class="lbl">{t('view_name')}</span>
|
|
100
|
+
<Input bind:value={name} placeholder="My open bugs" data-testid="view-name" />
|
|
101
|
+
</label>
|
|
102
|
+
<label class="grid gap-1">
|
|
103
|
+
<span class="lbl">{t('view_visibility')}</span>
|
|
104
|
+
<Select
|
|
105
|
+
value={visibility}
|
|
106
|
+
{options}
|
|
107
|
+
onValueChange={(v: string) => (visibility = v as typeof visibility)}
|
|
108
|
+
/>
|
|
109
|
+
</label>
|
|
110
|
+
<p class="preview" data-testid="view-preview">{kql || t('all_issues')}</p>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
{#snippet footer()}
|
|
114
|
+
<Button variant="ghost" size="sm" onclick={onclose}>{t('common.cancel')}</Button>
|
|
115
|
+
{#if editing}
|
|
116
|
+
<Button
|
|
117
|
+
variant="ghost"
|
|
118
|
+
size="sm"
|
|
119
|
+
loading={update.isPending}
|
|
120
|
+
onclick={() => update.mutate()}
|
|
121
|
+
data-testid="view-update"
|
|
122
|
+
>
|
|
123
|
+
{t('view_update', { name: editing.name })}
|
|
124
|
+
</Button>
|
|
125
|
+
{/if}
|
|
126
|
+
<Button
|
|
127
|
+
size="sm"
|
|
128
|
+
disabled={!name.trim()}
|
|
129
|
+
loading={save.isPending}
|
|
130
|
+
onclick={() => save.mutate()}
|
|
131
|
+
data-testid="view-save"
|
|
132
|
+
>
|
|
133
|
+
{editing ? t('view_save_as_new') : t('common.save')}
|
|
134
|
+
</Button>
|
|
135
|
+
{/snippet}
|
|
136
|
+
</Dialog>
|
|
137
|
+
|
|
138
|
+
<style>
|
|
139
|
+
.lbl {
|
|
140
|
+
font-size: 12px;
|
|
141
|
+
color: var(--kern-ink-550);
|
|
142
|
+
}
|
|
143
|
+
.preview {
|
|
144
|
+
margin: 0;
|
|
145
|
+
padding: 6px 8px;
|
|
146
|
+
border-radius: var(--kern-r-sm);
|
|
147
|
+
background: var(--kern-shell);
|
|
148
|
+
font-family: var(--kern-font-mono);
|
|
149
|
+
font-size: 12px;
|
|
150
|
+
color: var(--kern-ink-450, var(--kern-ink-400));
|
|
151
|
+
overflow-wrap: anywhere;
|
|
152
|
+
}
|
|
153
|
+
</style>
|