@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.
- 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/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,333 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Checkbox, DropdownMenu, IconButton, type MenuItem } from '@kernhq/ui'
|
|
3
|
+
import { getTrackerCatalogue } from '../context.svelte.js'
|
|
4
|
+
import { t } from '../i18n.js'
|
|
5
|
+
import type { FieldDef } from '../index.js'
|
|
6
|
+
import IssueInline from './IssueInline.svelte'
|
|
7
|
+
import IssuePicker from './IssuePicker.svelte'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One custom field, rendered and edited according to its type.
|
|
11
|
+
*
|
|
12
|
+
* Every field type the server accepts has a branch here. A type with no editor yet renders its
|
|
13
|
+
* value read-only and says why, rather than showing a control that silently does nothing.
|
|
14
|
+
*
|
|
15
|
+
* The value is committed on blur or on selection, never on every keystroke: each edit is its own
|
|
16
|
+
* mutation, and a request per character would be both slow and impossible to reason about when one
|
|
17
|
+
* of them fails.
|
|
18
|
+
*/
|
|
19
|
+
interface Props {
|
|
20
|
+
field: FieldDef
|
|
21
|
+
value: unknown
|
|
22
|
+
editable: boolean
|
|
23
|
+
required?: boolean
|
|
24
|
+
/** needed by the relation picker, which searches the workspace's issues */
|
|
25
|
+
workspaceId: string
|
|
26
|
+
/** the issue being edited, so a relation never offers to link it to itself */
|
|
27
|
+
issueId?: string | null
|
|
28
|
+
onchange: (value: unknown) => void
|
|
29
|
+
}
|
|
30
|
+
let { field, value, editable, required = false, workspaceId, issueId = null, onchange }: Props = $props()
|
|
31
|
+
|
|
32
|
+
/** Open only while somebody is choosing, so the row stays a row the rest of the time. */
|
|
33
|
+
let picking = $state(false)
|
|
34
|
+
|
|
35
|
+
const cat = getTrackerCatalogue()
|
|
36
|
+
|
|
37
|
+
const asText = $derived(typeof value === 'string' ? value : value == null ? '' : String(value))
|
|
38
|
+
const asArray = $derived(Array.isArray(value) ? (value as string[]) : value == null ? [] : [String(value)])
|
|
39
|
+
|
|
40
|
+
const options = $derived(field.options.filter((o) => !o.archived))
|
|
41
|
+
const optionLabel = (id: string) => options.find((o) => o.id === id)?.label ?? id
|
|
42
|
+
const personName = (id: string) => cat.people.find((p) => p.id === id)?.name ?? id
|
|
43
|
+
|
|
44
|
+
/** Empty string means "no value", which is a deletion — `null` is how the API says that. */
|
|
45
|
+
const commit = (next: unknown) => {
|
|
46
|
+
if (next === '' || (Array.isArray(next) && next.length === 0)) onchange(null)
|
|
47
|
+
else onchange(next)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const toggle = (list: string[], id: string, on: boolean) =>
|
|
51
|
+
on ? [...list.filter((v) => v !== id), id] : list.filter((v) => v !== id)
|
|
52
|
+
|
|
53
|
+
const selectMenu = $derived<MenuItem[]>([
|
|
54
|
+
...options.map((o) => ({
|
|
55
|
+
type: 'checkbox' as const,
|
|
56
|
+
id: o.id,
|
|
57
|
+
label: o.label,
|
|
58
|
+
checked: asText === o.id,
|
|
59
|
+
onCheckedChange: (on: boolean) => commit(on ? o.id : null),
|
|
60
|
+
})),
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
const multiSelectMenu = $derived<MenuItem[]>(
|
|
64
|
+
options.map((o) => ({
|
|
65
|
+
type: 'checkbox' as const,
|
|
66
|
+
id: o.id,
|
|
67
|
+
label: o.label,
|
|
68
|
+
checked: asArray.includes(o.id),
|
|
69
|
+
onCheckedChange: (on: boolean) => commit(toggle(asArray, o.id, on)),
|
|
70
|
+
})),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const userMenu = $derived<MenuItem[]>(
|
|
74
|
+
cat.people.map((person) => ({
|
|
75
|
+
type: 'checkbox' as const,
|
|
76
|
+
id: person.id,
|
|
77
|
+
label: person.name,
|
|
78
|
+
avatar: { id: person.id, name: person.name, src: person.avatarUrl },
|
|
79
|
+
checked: field.type === 'user' ? asText === person.id : asArray.includes(person.id),
|
|
80
|
+
onCheckedChange: (on: boolean) =>
|
|
81
|
+
field.type === 'user' ? commit(on ? person.id : null) : commit(toggle(asArray, person.id, on)),
|
|
82
|
+
})),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const dateValue = $derived(field.type === 'datetime' && asText ? asText.slice(0, 16) : asText.slice(0, 10))
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
{#if field.type === 'textarea'}
|
|
89
|
+
<!-- Bare rather than the boxed `Textarea`: a property row shows its value inline and only looks
|
|
90
|
+
like a control while you are in it (DESIGN.md 3.13). -->
|
|
91
|
+
<textarea
|
|
92
|
+
class="val-input"
|
|
93
|
+
value={asText}
|
|
94
|
+
rows="2"
|
|
95
|
+
disabled={!editable}
|
|
96
|
+
{required}
|
|
97
|
+
placeholder={editable ? t('field_empty') : ''}
|
|
98
|
+
aria-label={field.name}
|
|
99
|
+
onblur={(e) => commit(e.currentTarget.value)}
|
|
100
|
+
></textarea>
|
|
101
|
+
{:else if field.type === 'number' || field.type === 'text' || field.type === 'url'}
|
|
102
|
+
<input
|
|
103
|
+
class="val-input"
|
|
104
|
+
value={asText}
|
|
105
|
+
type={field.type === 'number' ? 'number' : field.type === 'url' ? 'url' : 'text'}
|
|
106
|
+
disabled={!editable}
|
|
107
|
+
{required}
|
|
108
|
+
min={field.config.min}
|
|
109
|
+
max={field.config.max}
|
|
110
|
+
maxlength={field.config.maxLength}
|
|
111
|
+
placeholder={editable ? (field.type === 'url' ? 'https://' : t('field_empty')) : ''}
|
|
112
|
+
aria-label={field.name}
|
|
113
|
+
onblur={(e) => {
|
|
114
|
+
const raw = e.currentTarget.value
|
|
115
|
+
commit(field.type === 'number' ? (raw === '' ? null : Number(raw)) : raw)
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
{:else if field.type === 'checkbox'}
|
|
119
|
+
<Checkbox
|
|
120
|
+
checked={value === true}
|
|
121
|
+
disabled={!editable}
|
|
122
|
+
ariaLabel={field.name}
|
|
123
|
+
onCheckedChange={(on: boolean) => onchange(on)}
|
|
124
|
+
/>
|
|
125
|
+
{:else if field.type === 'date' || field.type === 'datetime'}
|
|
126
|
+
<input
|
|
127
|
+
class="date"
|
|
128
|
+
type={field.type === 'datetime' ? 'datetime-local' : 'date'}
|
|
129
|
+
value={dateValue}
|
|
130
|
+
disabled={!editable}
|
|
131
|
+
{required}
|
|
132
|
+
aria-label={field.name}
|
|
133
|
+
onchange={(e) => commit(e.currentTarget.value ? new Date(e.currentTarget.value).toISOString() : null)}
|
|
134
|
+
/>
|
|
135
|
+
{:else if field.type === 'select' || field.type === 'multiselect' || field.type === 'label'}
|
|
136
|
+
{@const many = field.type !== 'select'}
|
|
137
|
+
<DropdownMenu items={editable ? (many ? multiSelectMenu : selectMenu) : []} align="start">
|
|
138
|
+
{#snippet trigger(props)}
|
|
139
|
+
<button
|
|
140
|
+
{...props}
|
|
141
|
+
type="button"
|
|
142
|
+
class="val wrap"
|
|
143
|
+
class:static={!editable}
|
|
144
|
+
disabled={!editable}
|
|
145
|
+
title={editable ? undefined : t('no_permission')}>
|
|
146
|
+
{#if many ? asArray.length === 0 : !asText}
|
|
147
|
+
<span class="muted">{t('field_empty')}</span>
|
|
148
|
+
{:else if many}
|
|
149
|
+
{#each asArray as id (id)}
|
|
150
|
+
<span class="chip">{optionLabel(id)}</span>
|
|
151
|
+
{/each}
|
|
152
|
+
{:else}
|
|
153
|
+
{optionLabel(asText)}
|
|
154
|
+
{/if}
|
|
155
|
+
</button>
|
|
156
|
+
{/snippet}
|
|
157
|
+
</DropdownMenu>
|
|
158
|
+
{:else if field.type === 'user' || field.type === 'multiuser'}
|
|
159
|
+
{@const many = field.type === 'multiuser'}
|
|
160
|
+
<DropdownMenu items={editable ? userMenu : []} align="start">
|
|
161
|
+
{#snippet trigger(props)}
|
|
162
|
+
<button
|
|
163
|
+
{...props}
|
|
164
|
+
type="button"
|
|
165
|
+
class="val wrap"
|
|
166
|
+
class:static={!editable}
|
|
167
|
+
disabled={!editable}
|
|
168
|
+
title={editable ? undefined : t('no_permission')}>
|
|
169
|
+
{#if many ? asArray.length === 0 : !asText}
|
|
170
|
+
<span class="muted">{t('field_empty')}</span>
|
|
171
|
+
{:else if many}
|
|
172
|
+
{#each asArray as id (id)}
|
|
173
|
+
<span class="chip">{personName(id)}</span>
|
|
174
|
+
{/each}
|
|
175
|
+
{:else}
|
|
176
|
+
{personName(asText)}
|
|
177
|
+
{/if}
|
|
178
|
+
</button>
|
|
179
|
+
{/snippet}
|
|
180
|
+
</DropdownMenu>
|
|
181
|
+
{:else if field.type === 'formula'}
|
|
182
|
+
<span class="val static" title={t('field_formula_hint')}>
|
|
183
|
+
{#if value === null || value === undefined}
|
|
184
|
+
<span class="muted">{t('field_empty')}</span>
|
|
185
|
+
{:else}
|
|
186
|
+
{String(value)}
|
|
187
|
+
{/if}
|
|
188
|
+
</span>
|
|
189
|
+
{:else if field.type === 'relation'}
|
|
190
|
+
<div class="rel">
|
|
191
|
+
{#if asArray.length}
|
|
192
|
+
<ul class="links">
|
|
193
|
+
{#each asArray as id (id)}
|
|
194
|
+
<li>
|
|
195
|
+
<IssueInline {id} />
|
|
196
|
+
{#if editable}
|
|
197
|
+
<IconButton
|
|
198
|
+
icon="x"
|
|
199
|
+
size={22}
|
|
200
|
+
label={t('relation_unlink')}
|
|
201
|
+
onclick={() => commit(asArray.filter((v) => v !== id))}
|
|
202
|
+
/>
|
|
203
|
+
{/if}
|
|
204
|
+
</li>
|
|
205
|
+
{/each}
|
|
206
|
+
</ul>
|
|
207
|
+
{/if}
|
|
208
|
+
{#if editable}
|
|
209
|
+
{#if picking}
|
|
210
|
+
<IssuePicker
|
|
211
|
+
{workspaceId}
|
|
212
|
+
exclude={[...asArray, ...(issueId ? [issueId] : [])]}
|
|
213
|
+
placeholder={t('relation_link')}
|
|
214
|
+
onpick={(issue) => {
|
|
215
|
+
// `relationMultiple: false` means one link, so a new pick replaces rather than appends.
|
|
216
|
+
commit(field.config.relationMultiple === false ? [issue.id] : [...asArray, issue.id])
|
|
217
|
+
picking = false
|
|
218
|
+
}}
|
|
219
|
+
oncancel={() => (picking = false)}
|
|
220
|
+
/>
|
|
221
|
+
{:else if field.config.relationMultiple !== false || asArray.length === 0}
|
|
222
|
+
<button type="button" class="link-btn" onclick={() => (picking = true)}>
|
|
223
|
+
{t('relation_link')}
|
|
224
|
+
</button>
|
|
225
|
+
{/if}
|
|
226
|
+
{:else if !asArray.length}
|
|
227
|
+
<span class="muted">{t('field_empty')}</span>
|
|
228
|
+
{/if}
|
|
229
|
+
</div>
|
|
230
|
+
{/if}
|
|
231
|
+
<style>
|
|
232
|
+
.val-input,
|
|
233
|
+
.date {
|
|
234
|
+
width: 100%;
|
|
235
|
+
min-height: 24px;
|
|
236
|
+
padding: 2px 6px;
|
|
237
|
+
margin-inline-start: -6px;
|
|
238
|
+
border: 1px solid transparent;
|
|
239
|
+
border-radius: var(--kern-r-sm);
|
|
240
|
+
background: none;
|
|
241
|
+
color: inherit;
|
|
242
|
+
font: inherit;
|
|
243
|
+
font-size: 13px;
|
|
244
|
+
resize: vertical;
|
|
245
|
+
}
|
|
246
|
+
.val-input:hover:not(:disabled),
|
|
247
|
+
.date:hover:not(:disabled) {
|
|
248
|
+
background: var(--kern-surface-active);
|
|
249
|
+
}
|
|
250
|
+
.val-input:focus,
|
|
251
|
+
.date:focus {
|
|
252
|
+
border-color: var(--kern-border);
|
|
253
|
+
background: var(--kern-surface);
|
|
254
|
+
outline: none;
|
|
255
|
+
}
|
|
256
|
+
.val-input:disabled,
|
|
257
|
+
.date:disabled {
|
|
258
|
+
cursor: default;
|
|
259
|
+
}
|
|
260
|
+
.val-input::placeholder {
|
|
261
|
+
color: var(--kern-ink-350);
|
|
262
|
+
}
|
|
263
|
+
.chip {
|
|
264
|
+
display: inline-flex;
|
|
265
|
+
align-items: center;
|
|
266
|
+
padding: 1px 6px;
|
|
267
|
+
border-radius: 999px;
|
|
268
|
+
background: var(--kern-surface-active);
|
|
269
|
+
font-size: 12px;
|
|
270
|
+
}
|
|
271
|
+
.muted {
|
|
272
|
+
color: var(--kern-ink-350);
|
|
273
|
+
}
|
|
274
|
+
.rel {
|
|
275
|
+
display: flex;
|
|
276
|
+
flex-direction: column;
|
|
277
|
+
gap: 4px;
|
|
278
|
+
}
|
|
279
|
+
.links {
|
|
280
|
+
list-style: none;
|
|
281
|
+
margin: 0;
|
|
282
|
+
padding: 0;
|
|
283
|
+
display: flex;
|
|
284
|
+
flex-direction: column;
|
|
285
|
+
gap: 2px;
|
|
286
|
+
}
|
|
287
|
+
.links li {
|
|
288
|
+
display: flex;
|
|
289
|
+
align-items: center;
|
|
290
|
+
gap: 4px;
|
|
291
|
+
}
|
|
292
|
+
.link-btn {
|
|
293
|
+
align-self: flex-start;
|
|
294
|
+
padding: 2px 6px;
|
|
295
|
+
margin-inline-start: -6px;
|
|
296
|
+
border: 0;
|
|
297
|
+
border-radius: var(--kern-r-sm);
|
|
298
|
+
background: none;
|
|
299
|
+
color: var(--kern-ink-350);
|
|
300
|
+
font: inherit;
|
|
301
|
+
font-size: 12px;
|
|
302
|
+
cursor: pointer;
|
|
303
|
+
}
|
|
304
|
+
.link-btn:hover {
|
|
305
|
+
background: var(--kern-surface-active);
|
|
306
|
+
color: var(--kern-ink-900);
|
|
307
|
+
}
|
|
308
|
+
.val {
|
|
309
|
+
display: inline-flex;
|
|
310
|
+
align-items: center;
|
|
311
|
+
gap: 6px;
|
|
312
|
+
min-height: 24px;
|
|
313
|
+
padding: 2px 6px;
|
|
314
|
+
margin-inline-start: -6px;
|
|
315
|
+
border-radius: var(--kern-r-sm);
|
|
316
|
+
background: none;
|
|
317
|
+
border: 0;
|
|
318
|
+
color: inherit;
|
|
319
|
+
font: inherit;
|
|
320
|
+
font-size: 13px;
|
|
321
|
+
text-align: start;
|
|
322
|
+
cursor: pointer;
|
|
323
|
+
}
|
|
324
|
+
.val.wrap {
|
|
325
|
+
flex-wrap: wrap;
|
|
326
|
+
}
|
|
327
|
+
.val:hover:not(.static) {
|
|
328
|
+
background: var(--kern-surface-active);
|
|
329
|
+
}
|
|
330
|
+
.val.static {
|
|
331
|
+
cursor: default;
|
|
332
|
+
}
|
|
333
|
+
</style>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { messageLocale } from '@kernhq/ui'
|
|
3
|
+
import { t } from '../i18n.js'
|
|
4
|
+
import { daysUntil, dueTone } from '../index.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A due date as the list and the board show it (DESIGN.md 3.0): anything due today, tomorrow or
|
|
8
|
+
* already late is drawn in danger red, everything else stays quiet.
|
|
9
|
+
*/
|
|
10
|
+
interface Props {
|
|
11
|
+
date: string
|
|
12
|
+
class?: string
|
|
13
|
+
}
|
|
14
|
+
let { date, class: className = '' }: Props = $props()
|
|
15
|
+
|
|
16
|
+
const days = $derived(daysUntil(date))
|
|
17
|
+
const tone = $derived(dueTone(date))
|
|
18
|
+
const text = $derived.by(() => {
|
|
19
|
+
if (days === 0) return t('due_today')
|
|
20
|
+
if (days === 1) return t('due_tomorrow')
|
|
21
|
+
const [y, mo, d] = date.split('-').map(Number)
|
|
22
|
+
const local = new Date(y ?? 1970, (mo ?? 1) - 1, d ?? 1)
|
|
23
|
+
const opts: Intl.DateTimeFormatOptions =
|
|
24
|
+
local.getFullYear() === new Date().getFullYear()
|
|
25
|
+
? { day: 'numeric', month: 'short' }
|
|
26
|
+
: { day: 'numeric', month: 'short', year: 'numeric' }
|
|
27
|
+
return new Intl.DateTimeFormat(messageLocale(), opts).format(local)
|
|
28
|
+
})
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<time datetime={date} class="kdue {tone} {className}" title={days < 0 ? t('due_overdue') : undefined}>
|
|
32
|
+
{text}
|
|
33
|
+
</time>
|
|
34
|
+
|
|
35
|
+
<style>
|
|
36
|
+
.kdue {
|
|
37
|
+
font-size: 12.5px;
|
|
38
|
+
white-space: nowrap;
|
|
39
|
+
color: var(--kern-ink-350);
|
|
40
|
+
}
|
|
41
|
+
.kdue.hot {
|
|
42
|
+
color: var(--kern-danger);
|
|
43
|
+
}
|
|
44
|
+
</style>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { DropdownMenu, type MenuItem, ToolbarButton } from '@kernhq/ui'
|
|
3
|
+
import { getTrackerCatalogue } from '../context.svelte.js'
|
|
4
|
+
import type { TrackerFilters } from '../filters.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import { PRIORITY_GROUP_ORDER, type Priority } from '../index.js'
|
|
7
|
+
import { priorityLabel } from '../labels.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The toolbar's Filter menu (DESIGN.md 2.5).
|
|
11
|
+
*
|
|
12
|
+
* Each section is a set of checkboxes over the workspace's own data, and the result is turned into
|
|
13
|
+
* KQL by `filters.ts`, so what the menu builds and what someone types in the query box are the same
|
|
14
|
+
* language and can be read side by side.
|
|
15
|
+
*/
|
|
16
|
+
interface Props {
|
|
17
|
+
filters: TrackerFilters
|
|
18
|
+
onchange: (next: TrackerFilters) => void
|
|
19
|
+
}
|
|
20
|
+
let { filters, onchange }: Props = $props()
|
|
21
|
+
|
|
22
|
+
const cat = getTrackerCatalogue()
|
|
23
|
+
|
|
24
|
+
function toggle<K extends keyof TrackerFilters>(key: K, value: string) {
|
|
25
|
+
const current = filters[key] as string[]
|
|
26
|
+
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]
|
|
27
|
+
onchange({ ...filters, [key]: next })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const items = $derived<MenuItem[]>([
|
|
31
|
+
{ type: 'label' as const, label: t('group_project') },
|
|
32
|
+
...cat.projects.map((p) => ({
|
|
33
|
+
type: 'checkbox' as const,
|
|
34
|
+
id: `p-${p.id}`,
|
|
35
|
+
label: p.name,
|
|
36
|
+
checked: filters.projectIds.includes(p.id),
|
|
37
|
+
onCheckedChange: () => toggle('projectIds', p.id),
|
|
38
|
+
})),
|
|
39
|
+
{ type: 'separator' as const },
|
|
40
|
+
{ type: 'label' as const, label: t('group_status') },
|
|
41
|
+
...cat.statuses.map((s) => ({
|
|
42
|
+
type: 'checkbox' as const,
|
|
43
|
+
id: `s-${s.id}`,
|
|
44
|
+
label: s.name,
|
|
45
|
+
checked: filters.statusIds.includes(s.id),
|
|
46
|
+
onCheckedChange: () => toggle('statusIds', s.id),
|
|
47
|
+
})),
|
|
48
|
+
{ type: 'separator' as const },
|
|
49
|
+
{ type: 'label' as const, label: t('group_priority') },
|
|
50
|
+
...PRIORITY_GROUP_ORDER.map((p: Priority) => ({
|
|
51
|
+
type: 'checkbox' as const,
|
|
52
|
+
id: `pr-${p}`,
|
|
53
|
+
label: priorityLabel(p),
|
|
54
|
+
checked: filters.priorities.includes(p),
|
|
55
|
+
onCheckedChange: () => toggle('priorities', p),
|
|
56
|
+
})),
|
|
57
|
+
{ type: 'separator' as const },
|
|
58
|
+
{ type: 'label' as const, label: t('group_assignee') },
|
|
59
|
+
...cat.people.map((person) => ({
|
|
60
|
+
type: 'checkbox' as const,
|
|
61
|
+
id: `a-${person.id}`,
|
|
62
|
+
label: person.name,
|
|
63
|
+
avatar: { id: person.id, name: person.name, src: person.avatarUrl },
|
|
64
|
+
checked: filters.assigneeIds.includes(person.id),
|
|
65
|
+
onCheckedChange: () => toggle('assigneeIds', person.id),
|
|
66
|
+
})),
|
|
67
|
+
{ type: 'separator' as const },
|
|
68
|
+
{ type: 'label' as const, label: t('group_label') },
|
|
69
|
+
...cat.labels.map((label) => ({
|
|
70
|
+
type: 'checkbox' as const,
|
|
71
|
+
id: `l-${label.id}`,
|
|
72
|
+
label: label.name,
|
|
73
|
+
checked: filters.labelIds.includes(label.id),
|
|
74
|
+
onCheckedChange: () => toggle('labelIds', label.id),
|
|
75
|
+
})),
|
|
76
|
+
])
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<DropdownMenu {items} align="start" class="kfilter-menu">
|
|
80
|
+
{#snippet trigger(props)}
|
|
81
|
+
<!-- the count belongs to the active-filter chip next to this button, not on the button itself -->
|
|
82
|
+
<ToolbarButton {...props} icon="filter" data-testid="filter-button">{t('filter')}</ToolbarButton>
|
|
83
|
+
{/snippet}
|
|
84
|
+
</DropdownMenu>
|
|
85
|
+
|
|
86
|
+
<style>
|
|
87
|
+
:global(.kfilter-menu) {
|
|
88
|
+
max-height: 60vh;
|
|
89
|
+
overflow-y: auto;
|
|
90
|
+
}
|
|
91
|
+
</style>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Avatar, Icon } from '@kernhq/ui'
|
|
3
|
+
import { getTrackerCatalogue } from '../context.svelte.js'
|
|
4
|
+
import { t } from '../i18n.js'
|
|
5
|
+
import type { StatusCategory } from '../index.js'
|
|
6
|
+
import type { GroupBadge } from '../labels.js'
|
|
7
|
+
import { estimateLabel } from '../labels.js'
|
|
8
|
+
import PriorityGlyph from './PriorityGlyph.svelte'
|
|
9
|
+
import StatusIcon from './StatusIcon.svelte'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The heading above each group of issues (DESIGN.md 3.2).
|
|
13
|
+
*
|
|
14
|
+
* The caret is a filled triangle that points right when the group is closed and down when it is
|
|
15
|
+
* open; the badge to its left is whatever the list is grouped by, so grouping by status shows the
|
|
16
|
+
* status glyph and grouping by assignee shows a face.
|
|
17
|
+
*/
|
|
18
|
+
interface Props {
|
|
19
|
+
label: string
|
|
20
|
+
count: number
|
|
21
|
+
estimate?: number | null
|
|
22
|
+
open?: boolean
|
|
23
|
+
badge?: GroupBadge
|
|
24
|
+
onToggle: () => void
|
|
25
|
+
onAdd?: () => void
|
|
26
|
+
}
|
|
27
|
+
let { label, count, estimate = null, open = true, badge, onToggle, onAdd }: Props = $props()
|
|
28
|
+
|
|
29
|
+
/** The unit a sum of estimates is said in, which belongs to the projects in view. */
|
|
30
|
+
const cat = getTrackerCatalogue()
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<div class="kgh">
|
|
34
|
+
<button type="button" class="head" aria-expanded={open} onclick={onToggle}>
|
|
35
|
+
<svg class="caret" class:open width="9" height="9" viewBox="0 0 24 24" aria-hidden="true">
|
|
36
|
+
<path d="M8 5l9 7-9 7z" fill="currentColor" />
|
|
37
|
+
</svg>
|
|
38
|
+
{#if badge?.kind === 'status'}
|
|
39
|
+
<StatusIcon category={badge.category as StatusCategory} statusId={badge.id} name={label} size={16} />
|
|
40
|
+
{:else if badge?.kind === 'priority'}
|
|
41
|
+
<PriorityGlyph priority={badge.priority} size={16} />
|
|
42
|
+
{:else if badge?.kind === 'person'}
|
|
43
|
+
<Avatar name={badge.name} src={badge.avatarUrl} id={badge.id} size={16} />
|
|
44
|
+
{:else if badge?.kind === 'colour'}
|
|
45
|
+
<span class="swatch" style:background={badge.color ?? 'var(--kern-ink-330)'}></span>
|
|
46
|
+
{/if}
|
|
47
|
+
<span class="name">{label}</span>
|
|
48
|
+
<span class="count">{count}</span>
|
|
49
|
+
</button>
|
|
50
|
+
<span class="sp"></span>
|
|
51
|
+
{#if estimate !== null && estimate > 0}
|
|
52
|
+
<span class="count">{estimateLabel(estimate, cat.estimateUnit)}</span>
|
|
53
|
+
{/if}
|
|
54
|
+
{#if onAdd}
|
|
55
|
+
<button type="button" class="add" aria-label={t('new_issue')} onclick={onAdd}>
|
|
56
|
+
<Icon name="plus" size={13} strokeWidth={1.9} />
|
|
57
|
+
</button>
|
|
58
|
+
{/if}
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<style>
|
|
62
|
+
.kgh {
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
gap: 10px;
|
|
66
|
+
height: 42px;
|
|
67
|
+
padding: 0 28px;
|
|
68
|
+
border-bottom: 1px solid var(--kern-border);
|
|
69
|
+
background: var(--kern-surface-header);
|
|
70
|
+
position: sticky;
|
|
71
|
+
top: 0;
|
|
72
|
+
z-index: 1;
|
|
73
|
+
}
|
|
74
|
+
.head {
|
|
75
|
+
display: flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
gap: 10px;
|
|
78
|
+
min-width: 0;
|
|
79
|
+
text-align: start;
|
|
80
|
+
}
|
|
81
|
+
.caret {
|
|
82
|
+
color: var(--kern-ink-350);
|
|
83
|
+
flex: none;
|
|
84
|
+
transition: transform 0.14s;
|
|
85
|
+
}
|
|
86
|
+
.caret.open {
|
|
87
|
+
transform: rotate(90deg);
|
|
88
|
+
}
|
|
89
|
+
:global([dir='rtl']) .caret {
|
|
90
|
+
transform: rotate(180deg);
|
|
91
|
+
}
|
|
92
|
+
:global([dir='rtl']) .caret.open {
|
|
93
|
+
transform: rotate(90deg);
|
|
94
|
+
}
|
|
95
|
+
.swatch {
|
|
96
|
+
width: 14px;
|
|
97
|
+
height: 14px;
|
|
98
|
+
border-radius: var(--kern-r-xs);
|
|
99
|
+
flex: none;
|
|
100
|
+
}
|
|
101
|
+
.name {
|
|
102
|
+
font-size: 13.5px;
|
|
103
|
+
font-weight: 600;
|
|
104
|
+
letter-spacing: -0.01em;
|
|
105
|
+
color: var(--kern-ink-900);
|
|
106
|
+
white-space: nowrap;
|
|
107
|
+
overflow: hidden;
|
|
108
|
+
text-overflow: ellipsis;
|
|
109
|
+
}
|
|
110
|
+
.count {
|
|
111
|
+
font-family: var(--kern-font-mono);
|
|
112
|
+
font-size: 11.5px;
|
|
113
|
+
letter-spacing: -0.01em;
|
|
114
|
+
color: var(--kern-ink-250);
|
|
115
|
+
white-space: nowrap;
|
|
116
|
+
}
|
|
117
|
+
.sp {
|
|
118
|
+
flex: 1;
|
|
119
|
+
}
|
|
120
|
+
.add {
|
|
121
|
+
width: 22px;
|
|
122
|
+
height: 22px;
|
|
123
|
+
border-radius: var(--kern-r-sm);
|
|
124
|
+
display: grid;
|
|
125
|
+
place-items: center;
|
|
126
|
+
color: var(--kern-ink-400);
|
|
127
|
+
flex: none;
|
|
128
|
+
}
|
|
129
|
+
.add:hover {
|
|
130
|
+
background: var(--kern-ghost-hover-dark);
|
|
131
|
+
}
|
|
132
|
+
@media (max-width: 768px) {
|
|
133
|
+
.kgh {
|
|
134
|
+
padding: 0 16px;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { navigation, SidebarGroup, SidebarItem } from '@kernhq/ui'
|
|
3
|
+
import { t } from '../i18n.js'
|
|
4
|
+
import { trackerHref } from '../nav.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the tracker puts on the home sidebar.
|
|
8
|
+
*
|
|
9
|
+
* These three rows lived in the application layout, which meant a workspace with the tracker
|
|
10
|
+
* switched off still saw them and still linked into it. They are the tracker's presets — real
|
|
11
|
+
* queries the issues screen reads out of the URL — so they belong to the tracker, and appear only
|
|
12
|
+
* when it is enabled and the reader may see issues.
|
|
13
|
+
*/
|
|
14
|
+
interface Props {
|
|
15
|
+
workspaceSlug: string
|
|
16
|
+
}
|
|
17
|
+
let { workspaceSlug }: Props = $props()
|
|
18
|
+
|
|
19
|
+
const params = $derived(new URLSearchParams(navigation.search))
|
|
20
|
+
const inTracker = $derived(navigation.pathname === `/${workspaceSlug}/tracker`)
|
|
21
|
+
|
|
22
|
+
const rows = [
|
|
23
|
+
{ preset: 'assigned', icon: 'circle-user', label: () => t('preset_assigned') },
|
|
24
|
+
{ preset: 'created', icon: 'square-pen', label: () => t('preset_created') },
|
|
25
|
+
{ preset: 'subscribed', icon: 'eye', label: () => t('preset_subscribed') },
|
|
26
|
+
] as const
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<SidebarGroup title={t('title')}>
|
|
30
|
+
{#each rows as row (row.preset)}
|
|
31
|
+
<SidebarItem
|
|
32
|
+
label={row.label()}
|
|
33
|
+
icon={row.icon}
|
|
34
|
+
href={trackerHref(workspaceSlug, { preset: row.preset })}
|
|
35
|
+
active={inTracker && params.get('preset') === row.preset}
|
|
36
|
+
/>
|
|
37
|
+
{/each}
|
|
38
|
+
</SidebarGroup>
|