@kernhq/module-tracker 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +16 -3
  2. package/src/client/api-instance.ts +34 -0
  3. package/src/client/components/BoardCard.svelte +182 -0
  4. package/src/client/components/BoardView.svelte +248 -0
  5. package/src/client/components/CommentComposer.svelte +219 -0
  6. package/src/client/components/CommentThread.svelte +389 -0
  7. package/src/client/components/CustomField.svelte +333 -0
  8. package/src/client/components/DueDate.svelte +44 -0
  9. package/src/client/components/FilterMenu.svelte +91 -0
  10. package/src/client/components/GroupHeader.svelte +137 -0
  11. package/src/client/components/HomeLinks.svelte +38 -0
  12. package/src/client/components/IssueApprovals.svelte +217 -0
  13. package/src/client/components/IssueConnections.svelte +424 -0
  14. package/src/client/components/IssueDetailPanel.svelte +1337 -0
  15. package/src/client/components/IssueInline.svelte +75 -0
  16. package/src/client/components/IssueListView.svelte +153 -0
  17. package/src/client/components/IssuePicker.svelte +180 -0
  18. package/src/client/components/IssueRow.svelte +171 -0
  19. package/src/client/components/IssueTime.svelte +306 -0
  20. package/src/client/components/KqlInput.svelte +295 -0
  21. package/src/client/components/NewIssueDialog.svelte +210 -0
  22. package/src/client/components/NewProjectDialog.svelte +235 -0
  23. package/src/client/components/PriorityGlyph.svelte +52 -0
  24. package/src/client/components/SaveViewDialog.svelte +153 -0
  25. package/src/client/components/SidebarProjects.svelte +263 -0
  26. package/src/client/components/SidebarViews.svelte +336 -0
  27. package/src/client/components/StatusIcon.svelte +50 -0
  28. package/src/client/components/TrackerControls.svelte +109 -0
  29. package/src/client/components/TrackerSidebar.svelte +96 -0
  30. package/src/client/context.svelte.ts +105 -0
  31. package/src/client/core-api.ts +35 -0
  32. package/src/client/csv.test.ts +50 -0
  33. package/src/client/csv.ts +60 -0
  34. package/src/client/filters.ts +94 -0
  35. package/src/client/i18n.ts +3260 -0
  36. package/src/client/index.ts +12 -0
  37. package/src/client/labels.ts +200 -0
  38. package/src/client/mock.ts +2729 -0
  39. package/src/client/module.ts +491 -0
  40. package/src/client/nav.test.ts +59 -0
  41. package/src/client/nav.ts +84 -0
  42. package/src/client/pages/IntakePage.svelte +287 -0
  43. package/src/client/pages/IssuesPage.svelte +701 -0
  44. package/src/client/pages/ProjectPage.svelte +29 -0
  45. package/src/client/pages/ReportsPage.svelte +365 -0
  46. package/src/client/permissions.ts +33 -0
  47. package/src/client/planning/PlanningSections.svelte +259 -0
  48. package/src/client/project/ComponentsPage.svelte +423 -0
  49. package/src/client/project/CyclesPage.svelte +488 -0
  50. package/src/client/project/MilestonesPage.svelte +342 -0
  51. package/src/client/project/PlanCard.svelte +191 -0
  52. package/src/client/project/ProjectShell.svelte +93 -0
  53. package/src/client/project/TemplatesPage.svelte +321 -0
  54. package/src/client/project/context.svelte.ts +58 -0
  55. package/src/client/query.ts +50 -0
  56. package/src/client/recurrence.test.ts +37 -0
  57. package/src/client/recurrence.ts +89 -0
  58. package/src/client/richtext.ts +155 -0
  59. package/src/client/settings/CycleList.svelte +422 -0
  60. package/src/client/settings/FieldEditor.svelte +324 -0
  61. package/src/client/settings/FieldsSettings.svelte +273 -0
  62. package/src/client/settings/ImportSettings.svelte +410 -0
  63. package/src/client/settings/LayoutEditor.svelte +263 -0
  64. package/src/client/settings/PlanningList.svelte +249 -0
  65. package/src/client/settings/PlanningSettings.svelte +179 -0
  66. package/src/client/settings/ProjectsSettings.svelte +462 -0
  67. package/src/client/settings/RepeatingSettings.svelte +293 -0
  68. package/src/client/settings/TypeEditor.svelte +214 -0
  69. package/src/client/settings/TypesSettings.svelte +384 -0
  70. package/src/client/settings/WorkflowsSettings.svelte +645 -0
  71. package/src/client/settings/mutations.ts +19 -0
  72. package/src/client/time.test.ts +39 -0
  73. package/src/client/time.ts +34 -0
  74. package/src/client/tracker.test.ts +399 -0
  75. package/src/client/views.ts +27 -0
  76. package/src/client/widgets/AssignedCountWidget.svelte +14 -0
  77. package/src/client/widgets/CountWidget.svelte +56 -0
  78. package/src/client/widgets/CycleWidget.svelte +85 -0
  79. package/src/client/widgets/DueSoonCountWidget.svelte +14 -0
  80. package/src/client/widgets/IssuesWidget.svelte +222 -0
  81. package/src/client/widgets/ThroughputWidget.svelte +68 -0
  82. package/src/client/widgets/TimerWidget.svelte +116 -0
  83. 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>