@kernhq/module-tracker 0.10.1 → 0.11.1
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 +19 -6
- 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,263 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
DropdownMenu,
|
|
4
|
+
formatCount,
|
|
5
|
+
IconButton,
|
|
6
|
+
type MenuItem,
|
|
7
|
+
navigation,
|
|
8
|
+
SidebarGroup,
|
|
9
|
+
SidebarItem,
|
|
10
|
+
SidebarPill,
|
|
11
|
+
Skeleton,
|
|
12
|
+
session,
|
|
13
|
+
} from '@kernhq/ui'
|
|
14
|
+
import { createQuery } from '@tanstack/svelte-query'
|
|
15
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
16
|
+
import { t } from '../i18n.js'
|
|
17
|
+
import type { Project } from '../index.js'
|
|
18
|
+
import {
|
|
19
|
+
isTrackerTarget,
|
|
20
|
+
type ProjectSection,
|
|
21
|
+
projectColor,
|
|
22
|
+
projectSectionHref,
|
|
23
|
+
projectSettingsHref,
|
|
24
|
+
projectTargets,
|
|
25
|
+
type TrackerTarget,
|
|
26
|
+
trackerHref,
|
|
27
|
+
} from '../nav.js'
|
|
28
|
+
import { canTracker } from '../permissions.js'
|
|
29
|
+
import { trackerKeys } from '../query.js'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The workspace's projects, in the sidebar (DESIGN.md 2.3).
|
|
33
|
+
*
|
|
34
|
+
* A project is a heading with the ways its work is looked at underneath — its issues, and the
|
|
35
|
+
* planning it actually uses. Cycles and triage appear only for the projects that turned them on:
|
|
36
|
+
* a row that leads to an empty screen for most of a workspace is a row people learn to ignore.
|
|
37
|
+
*
|
|
38
|
+
* The rows open the project's own pages, where a component is added and a milestone marked
|
|
39
|
+
* reached. What is genuinely configuration — repeating work, the project's settings — stays in the
|
|
40
|
+
* menu, because those are places you go once and the rows above are places you go daily.
|
|
41
|
+
*/
|
|
42
|
+
interface Props {
|
|
43
|
+
/** the sidebar's own "new project", so the dialog opens on the page you are on */
|
|
44
|
+
oncreate?: () => void
|
|
45
|
+
}
|
|
46
|
+
let { oncreate }: Props = $props()
|
|
47
|
+
|
|
48
|
+
const api = getTrackerApi()
|
|
49
|
+
|
|
50
|
+
const slug = $derived(navigation.workspaceSlug)
|
|
51
|
+
const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
|
|
52
|
+
const params = $derived(new URLSearchParams(navigation.search))
|
|
53
|
+
const inTracker = $derived(navigation.pathname === `/${slug}/tracker`)
|
|
54
|
+
const canManage = $derived(canTracker('projectManage'))
|
|
55
|
+
|
|
56
|
+
const projectsQuery = createQuery(() => ({
|
|
57
|
+
queryKey: trackerKeys.projects(workspaceId),
|
|
58
|
+
queryFn: () => api.projects.list({ workspaceId }),
|
|
59
|
+
enabled: Boolean(workspaceId),
|
|
60
|
+
}))
|
|
61
|
+
const projects = $derived(projectsQuery.data ?? [])
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Which projects are folded away, kept on the device.
|
|
65
|
+
*
|
|
66
|
+
* Somebody with eight projects works in one of them; re-opening the same three headings on every
|
|
67
|
+
* visit is the sort of small tax that makes a sidebar feel like it is not listening. Collapsed
|
|
68
|
+
* rather than expanded is stored, so a project created later arrives open.
|
|
69
|
+
*/
|
|
70
|
+
const STORAGE_KEY = 'kern.tracker.projects.collapsed'
|
|
71
|
+
let collapsed = $state<string[]>(read())
|
|
72
|
+
|
|
73
|
+
function read(): string[] {
|
|
74
|
+
if (typeof localStorage === 'undefined') return []
|
|
75
|
+
try {
|
|
76
|
+
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]') as unknown
|
|
77
|
+
return Array.isArray(raw) ? raw.filter((id): id is string => typeof id === 'string') : []
|
|
78
|
+
} catch {
|
|
79
|
+
return []
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toggle(id: string) {
|
|
84
|
+
collapsed = collapsed.includes(id) ? collapsed.filter((c) => c !== id) : [...collapsed, id]
|
|
85
|
+
if (typeof localStorage === 'undefined') return
|
|
86
|
+
try {
|
|
87
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(collapsed))
|
|
88
|
+
} catch {
|
|
89
|
+
// private browsing and a full quota both land here; the fold still applies to this session
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The rows under one project.
|
|
95
|
+
*
|
|
96
|
+
* Two kinds, and the difference matters: a query row narrows the issue list, a section row opens
|
|
97
|
+
* the project's own page. Components, milestones and cycles are things a team makes and manages —
|
|
98
|
+
* they are pages you work on, not sortings of a list.
|
|
99
|
+
*/
|
|
100
|
+
type ProjectRow = { id: string; label: string; icon: string } & (
|
|
101
|
+
| { target: TrackerTarget; section?: undefined }
|
|
102
|
+
| { section: ProjectSection; target?: undefined }
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
function rowsFor(project: Project): ProjectRow[] {
|
|
106
|
+
const targets = projectTargets(project.id)
|
|
107
|
+
return [
|
|
108
|
+
{ id: 'issues', label: t('nav'), icon: 'square-check-big', target: targets.issues },
|
|
109
|
+
{ id: 'components', label: t('planning_components'), icon: 'puzzle', section: 'components' },
|
|
110
|
+
{ id: 'milestones', label: t('planning_milestones'), icon: 'flag', section: 'milestones' },
|
|
111
|
+
...(project.settings.cycles.enabled
|
|
112
|
+
? [
|
|
113
|
+
{
|
|
114
|
+
id: 'cycles',
|
|
115
|
+
label: t('planning_cycles'),
|
|
116
|
+
icon: 'refresh-cw',
|
|
117
|
+
section: 'cycles' as const,
|
|
118
|
+
},
|
|
119
|
+
]
|
|
120
|
+
: []),
|
|
121
|
+
{ id: 'templates', label: t('template_title'), icon: 'copy', section: 'templates' },
|
|
122
|
+
...(project.settings.triage.enabled
|
|
123
|
+
? [{ id: 'triage', label: t('triage_title'), icon: 'inbox', target: targets.triage }]
|
|
124
|
+
: []),
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Where a row goes, and whether the screen is already showing it. */
|
|
129
|
+
const hrefFor = (project: Project, row: ProjectRow) =>
|
|
130
|
+
row.section ? projectSectionHref(slug, project.key, row.section) : trackerHref(slug, row.target)
|
|
131
|
+
|
|
132
|
+
const isCurrent = (project: Project, row: ProjectRow) =>
|
|
133
|
+
row.section
|
|
134
|
+
? navigation.pathname === projectSectionHref(slug, project.key, row.section)
|
|
135
|
+
: inTracker && isTrackerTarget(params, row.target)
|
|
136
|
+
|
|
137
|
+
const menuFor = (project: Project): MenuItem[] => [
|
|
138
|
+
...(canTracker('create')
|
|
139
|
+
? [
|
|
140
|
+
{
|
|
141
|
+
type: 'item' as const,
|
|
142
|
+
id: 'new-issue',
|
|
143
|
+
label: t('new_issue'),
|
|
144
|
+
icon: 'plus',
|
|
145
|
+
// the list it filters stays underneath, and the dialog opens on that project
|
|
146
|
+
href: `${trackerHref(slug, projectTargets(project.id).issues)}&new=1&project=${project.id}`,
|
|
147
|
+
},
|
|
148
|
+
]
|
|
149
|
+
: []),
|
|
150
|
+
...(canManage
|
|
151
|
+
? [
|
|
152
|
+
{ type: 'separator' as const },
|
|
153
|
+
{
|
|
154
|
+
type: 'item' as const,
|
|
155
|
+
id: 'repeating',
|
|
156
|
+
label: t('settings_repeating'),
|
|
157
|
+
icon: 'refresh-cw',
|
|
158
|
+
href: projectSettingsHref(slug, 'repeating', project.id),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
type: 'item' as const,
|
|
162
|
+
id: 'settings',
|
|
163
|
+
label: t('project_settings'),
|
|
164
|
+
icon: 'settings',
|
|
165
|
+
href: projectSettingsHref(slug, 'projects', project.id),
|
|
166
|
+
},
|
|
167
|
+
]
|
|
168
|
+
: []),
|
|
169
|
+
]
|
|
170
|
+
</script>
|
|
171
|
+
|
|
172
|
+
<SidebarGroup title={t('projects_yours')} count={projects.length || null}>
|
|
173
|
+
{#snippet trailing()}
|
|
174
|
+
{#if canManage}
|
|
175
|
+
<IconButton
|
|
176
|
+
icon="plus"
|
|
177
|
+
label={t('project_new')}
|
|
178
|
+
size={22}
|
|
179
|
+
onclick={() => oncreate?.()}
|
|
180
|
+
data-testid="sidebar-new-project"
|
|
181
|
+
/>
|
|
182
|
+
{/if}
|
|
183
|
+
{/snippet}
|
|
184
|
+
|
|
185
|
+
{#if projectsQuery.isPending}
|
|
186
|
+
<div class="loading">
|
|
187
|
+
{#each [1, 2, 3] as row (row)}<Skeleton class="h-[26px] w-full" />{/each}
|
|
188
|
+
</div>
|
|
189
|
+
{:else if !projects.length}
|
|
190
|
+
<p class="none">{t('projects_empty_hint')}</p>
|
|
191
|
+
{:else}
|
|
192
|
+
{#each projects as project (project.id)}
|
|
193
|
+
{@const open = !collapsed.includes(project.id)}
|
|
194
|
+
{@const menu = menuFor(project)}
|
|
195
|
+
<div class="prow">
|
|
196
|
+
<SidebarPill
|
|
197
|
+
label={project.name}
|
|
198
|
+
color={projectColor(project)}
|
|
199
|
+
aria-expanded={open}
|
|
200
|
+
onclick={() => toggle(project.id)}
|
|
201
|
+
title="{project.key} · {project.name}"
|
|
202
|
+
data-testid="sidebar-project"
|
|
203
|
+
data-project={project.key}
|
|
204
|
+
/>
|
|
205
|
+
{#if menu.length}
|
|
206
|
+
<DropdownMenu items={menu} align="end">
|
|
207
|
+
{#snippet trigger(props)}
|
|
208
|
+
<IconButton
|
|
209
|
+
{...props}
|
|
210
|
+
icon="ellipsis"
|
|
211
|
+
size={22}
|
|
212
|
+
class="pmenu"
|
|
213
|
+
label={t('project_actions', { name: project.name })}
|
|
214
|
+
/>
|
|
215
|
+
{/snippet}
|
|
216
|
+
</DropdownMenu>
|
|
217
|
+
{/if}
|
|
218
|
+
</div>
|
|
219
|
+
{#if open}
|
|
220
|
+
{#each rowsFor(project) as row (row.id)}
|
|
221
|
+
<SidebarItem
|
|
222
|
+
label={row.label}
|
|
223
|
+
icon={row.icon}
|
|
224
|
+
indent={1}
|
|
225
|
+
href={hrefFor(project, row)}
|
|
226
|
+
active={isCurrent(project, row)}
|
|
227
|
+
badge={row.id === 'issues' && project.openIssueCount ? formatCount(project.openIssueCount) : null}
|
|
228
|
+
data-testid="project-{row.id}"
|
|
229
|
+
data-project={project.key}
|
|
230
|
+
/>
|
|
231
|
+
{/each}
|
|
232
|
+
{/if}
|
|
233
|
+
{/each}
|
|
234
|
+
{/if}
|
|
235
|
+
</SidebarGroup>
|
|
236
|
+
|
|
237
|
+
<style>
|
|
238
|
+
/* The pill hugs its name (DESIGN.md 2.3) and the menu sits at the row's end regardless. */
|
|
239
|
+
.prow {
|
|
240
|
+
display: flex;
|
|
241
|
+
align-items: center;
|
|
242
|
+
gap: 4px;
|
|
243
|
+
}
|
|
244
|
+
.prow :global(.kspill) {
|
|
245
|
+
min-width: 0;
|
|
246
|
+
max-width: calc(100% - 26px);
|
|
247
|
+
}
|
|
248
|
+
.prow :global(.pmenu) {
|
|
249
|
+
margin-inline-start: auto;
|
|
250
|
+
}
|
|
251
|
+
/* A sidebar has no room for an illustrated empty state; one quiet line says the same thing. */
|
|
252
|
+
.none {
|
|
253
|
+
margin: 2px 10px 6px;
|
|
254
|
+
font-size: 12.5px;
|
|
255
|
+
color: var(--kern-ink-330);
|
|
256
|
+
}
|
|
257
|
+
.loading {
|
|
258
|
+
display: flex;
|
|
259
|
+
flex-direction: column;
|
|
260
|
+
gap: 4px;
|
|
261
|
+
padding: 4px 2px;
|
|
262
|
+
}
|
|
263
|
+
</style>
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Dialog,
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
Icon,
|
|
7
|
+
IconButton,
|
|
8
|
+
Input,
|
|
9
|
+
type MenuItem,
|
|
10
|
+
navigation,
|
|
11
|
+
Select,
|
|
12
|
+
SidebarGroup,
|
|
13
|
+
Skeleton,
|
|
14
|
+
session,
|
|
15
|
+
toast,
|
|
16
|
+
} from '@kernhq/ui'
|
|
17
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
18
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
19
|
+
import { t } from '../i18n.js'
|
|
20
|
+
import type { View } from '../index.js'
|
|
21
|
+
import { canTracker } from '../permissions.js'
|
|
22
|
+
import { trackerKeys } from '../query.js'
|
|
23
|
+
import { viewHref } from '../views.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Saved views, in the application sidebar (DESIGN.md 2.3).
|
|
27
|
+
*
|
|
28
|
+
* A view is a query somebody named: the KQL, the layout, the grouping and the filters, kept
|
|
29
|
+
* together so "the bugs I own that are overdue" is one click rather than four. The server has had
|
|
30
|
+
* them since the module existed and nothing offered them.
|
|
31
|
+
*
|
|
32
|
+
* Pinned views come first because they are the ones somebody uses daily; the rest follow under a
|
|
33
|
+
* quieter heading rather than in a menu, because a view you cannot see is a view you forget you
|
|
34
|
+
* saved.
|
|
35
|
+
*/
|
|
36
|
+
interface Props {
|
|
37
|
+
/** the sidebar's "+": save what the screen is showing right now, without leaving it */
|
|
38
|
+
onsave?: () => void
|
|
39
|
+
}
|
|
40
|
+
let { onsave }: Props = $props()
|
|
41
|
+
|
|
42
|
+
const api = getTrackerApi()
|
|
43
|
+
const queryClient = useQueryClient()
|
|
44
|
+
|
|
45
|
+
const slug = $derived(navigation.workspaceSlug)
|
|
46
|
+
const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
|
|
47
|
+
const canManageShared = $derived(canTracker('viewManageShared'))
|
|
48
|
+
/** Saving a view is saving a query, which anyone who may raise work may do. */
|
|
49
|
+
const canSave = $derived(canTracker('create'))
|
|
50
|
+
|
|
51
|
+
const viewsQuery = createQuery(() => ({
|
|
52
|
+
queryKey: trackerKeys.views(workspaceId),
|
|
53
|
+
queryFn: () => api.views.list({ workspaceId }),
|
|
54
|
+
enabled: Boolean(workspaceId),
|
|
55
|
+
}))
|
|
56
|
+
const views = $derived(viewsQuery.data ?? [])
|
|
57
|
+
const pinned = $derived(views.filter((v) => v.pinned))
|
|
58
|
+
const rest = $derived(views.filter((v) => !v.pinned))
|
|
59
|
+
|
|
60
|
+
/** The view the page is showing, so the sidebar marks it the way the nav marks a page. */
|
|
61
|
+
const activeId = $derived(navigation.search.view_id)
|
|
62
|
+
|
|
63
|
+
let editing = $state<View | null>(null)
|
|
64
|
+
let draftName = $state('')
|
|
65
|
+
let draftVisibility = $state<View['visibility']>('private')
|
|
66
|
+
/** Named on screen before it happens, never straight off a menu. */
|
|
67
|
+
let confirming = $state<View | null>(null)
|
|
68
|
+
|
|
69
|
+
const invalidate = () => queryClient.invalidateQueries({ queryKey: trackerKeys.views(workspaceId) })
|
|
70
|
+
const fail = (error: Error) => toast.error(error.message)
|
|
71
|
+
|
|
72
|
+
const pin = createMutation(() => ({
|
|
73
|
+
mutationFn: (input: { id: string; pinned: boolean }) => api.views.pin({ workspaceId, ...input }),
|
|
74
|
+
onSuccess: invalidate,
|
|
75
|
+
onError: fail,
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
/** A view can be renamed and reshared. `views.update` existed from the start and nothing called
|
|
79
|
+
* it, so a view named in a hurry kept that name and a private one stayed private. */
|
|
80
|
+
const rename = createMutation(() => ({
|
|
81
|
+
mutationFn: () =>
|
|
82
|
+
api.views.update({
|
|
83
|
+
workspaceId,
|
|
84
|
+
id: editing?.id as string,
|
|
85
|
+
patch: { name: draftName.trim(), visibility: draftVisibility },
|
|
86
|
+
} as never),
|
|
87
|
+
onSuccess: () => {
|
|
88
|
+
editing = null
|
|
89
|
+
invalidate()
|
|
90
|
+
},
|
|
91
|
+
onError: fail,
|
|
92
|
+
}))
|
|
93
|
+
|
|
94
|
+
const remove = createMutation(() => ({
|
|
95
|
+
mutationFn: (id: string) => api.views.delete({ workspaceId, id }),
|
|
96
|
+
onSuccess: () => {
|
|
97
|
+
confirming = null
|
|
98
|
+
invalidate()
|
|
99
|
+
// Leaving the deleted view in the URL would reopen a query that no longer exists.
|
|
100
|
+
if (activeId) void navigation.go(`/${slug}/tracker`, { replaceState: true, keepFocus: true })
|
|
101
|
+
},
|
|
102
|
+
onError: fail,
|
|
103
|
+
}))
|
|
104
|
+
|
|
105
|
+
/** Who may change a view: your own always, a shared one only with the permission for it. */
|
|
106
|
+
const visibilityOptions = $derived([
|
|
107
|
+
{ value: 'private', label: t('view_private') },
|
|
108
|
+
...(canManageShared
|
|
109
|
+
? [
|
|
110
|
+
{ value: 'project', label: t('view_project') },
|
|
111
|
+
{ value: 'workspace', label: t('view_workspace') },
|
|
112
|
+
]
|
|
113
|
+
: []),
|
|
114
|
+
])
|
|
115
|
+
|
|
116
|
+
const mayEdit = (view: View) =>
|
|
117
|
+
!view.builtin && (view.visibility === 'private' ? view.ownerId === session.user?.id : canManageShared)
|
|
118
|
+
|
|
119
|
+
const openEditor = (view: View) => {
|
|
120
|
+
editing = view
|
|
121
|
+
draftName = view.name
|
|
122
|
+
draftVisibility = view.visibility
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const menuFor = (view: View): MenuItem[] => [
|
|
126
|
+
{
|
|
127
|
+
type: 'item',
|
|
128
|
+
id: 'pin',
|
|
129
|
+
label: view.pinned ? t('view_unpin') : t('view_pin'),
|
|
130
|
+
icon: 'star',
|
|
131
|
+
onSelect: () => pin.mutate({ id: view.id, pinned: !view.pinned }),
|
|
132
|
+
},
|
|
133
|
+
...(mayEdit(view)
|
|
134
|
+
? [
|
|
135
|
+
{
|
|
136
|
+
type: 'item' as const,
|
|
137
|
+
id: 'rename',
|
|
138
|
+
label: t('view_rename'),
|
|
139
|
+
icon: 'pencil',
|
|
140
|
+
onSelect: () => openEditor(view),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
type: 'item' as const,
|
|
144
|
+
id: 'delete',
|
|
145
|
+
label: t('common.delete'),
|
|
146
|
+
icon: 'trash-2',
|
|
147
|
+
danger: true,
|
|
148
|
+
// Asked for, not fired from a menu — the only irreversible entry in it.
|
|
149
|
+
onSelect: () => (confirming = view),
|
|
150
|
+
},
|
|
151
|
+
]
|
|
152
|
+
: []),
|
|
153
|
+
]
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
{#snippet row(view: View)}
|
|
157
|
+
<li>
|
|
158
|
+
<a
|
|
159
|
+
href={viewHref(slug, view)}
|
|
160
|
+
aria-current={activeId === view.id ? 'page' : undefined}
|
|
161
|
+
class="vrow"
|
|
162
|
+
class:on={activeId === view.id}
|
|
163
|
+
data-testid="saved-view"
|
|
164
|
+
data-view-name={view.name}
|
|
165
|
+
>
|
|
166
|
+
<Icon name={view.icon ?? (view.layout === 'board' ? 'columns-3' : 'list')} size={14} strokeWidth={1.8} />
|
|
167
|
+
<span class="vname">{view.name}</span>
|
|
168
|
+
</a>
|
|
169
|
+
<DropdownMenu items={menuFor(view)} align="end">
|
|
170
|
+
{#snippet trigger(props)}
|
|
171
|
+
<IconButton {...props} icon="ellipsis" size={22} label={t('view_actions', { name: view.name })} />
|
|
172
|
+
{/snippet}
|
|
173
|
+
</DropdownMenu>
|
|
174
|
+
</li>
|
|
175
|
+
{/snippet}
|
|
176
|
+
|
|
177
|
+
{#if pinned.length}
|
|
178
|
+
<SidebarGroup title={t('views_pinned')} count={pinned.length}>
|
|
179
|
+
<ul>{#each pinned as view (view.id)}{@render row(view)}{/each}</ul>
|
|
180
|
+
</SidebarGroup>
|
|
181
|
+
{/if}
|
|
182
|
+
|
|
183
|
+
<SidebarGroup title={t('views_all')} count={rest.length || null}>
|
|
184
|
+
{#snippet trailing()}
|
|
185
|
+
{#if canSave}
|
|
186
|
+
<IconButton
|
|
187
|
+
icon="plus"
|
|
188
|
+
label={t('view_save_current')}
|
|
189
|
+
size={22}
|
|
190
|
+
onclick={() => onsave?.()}
|
|
191
|
+
data-testid="sidebar-save-view"
|
|
192
|
+
/>
|
|
193
|
+
{/if}
|
|
194
|
+
{/snippet}
|
|
195
|
+
|
|
196
|
+
{#if viewsQuery.isPending}
|
|
197
|
+
<div class="loading">
|
|
198
|
+
{#each [1, 2, 3] as row (row)}<Skeleton class="h-[26px] w-full" />{/each}
|
|
199
|
+
</div>
|
|
200
|
+
{:else if !views.length}
|
|
201
|
+
<p class="none">{t('views_empty_hint')}</p>
|
|
202
|
+
{:else}
|
|
203
|
+
<ul>{#each rest as view (view.id)}{@render row(view)}{/each}</ul>
|
|
204
|
+
{/if}
|
|
205
|
+
</SidebarGroup>
|
|
206
|
+
|
|
207
|
+
<Dialog
|
|
208
|
+
open={editing !== null}
|
|
209
|
+
title={t('view_rename')}
|
|
210
|
+
size="sm"
|
|
211
|
+
onOpenChange={(next: boolean) => {
|
|
212
|
+
if (!next) editing = null
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<div class="egrid">
|
|
216
|
+
<label class="erow">
|
|
217
|
+
<span class="elbl">{t('view_name')}</span>
|
|
218
|
+
<Input bind:value={draftName} data-testid="view-rename-name" />
|
|
219
|
+
</label>
|
|
220
|
+
<label class="erow">
|
|
221
|
+
<span class="elbl">{t('view_visibility')}</span>
|
|
222
|
+
<Select
|
|
223
|
+
value={draftVisibility}
|
|
224
|
+
options={visibilityOptions}
|
|
225
|
+
onValueChange={(v: string) => (draftVisibility = v as View['visibility'])}
|
|
226
|
+
/>
|
|
227
|
+
</label>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
{#snippet footer()}
|
|
231
|
+
<Button variant="ghost" size="sm" onclick={() => (editing = null)}>{t('common.cancel')}</Button>
|
|
232
|
+
<Button
|
|
233
|
+
size="sm"
|
|
234
|
+
disabled={!draftName.trim()}
|
|
235
|
+
loading={rename.isPending}
|
|
236
|
+
onclick={() => rename.mutate()}
|
|
237
|
+
data-testid="view-rename-save"
|
|
238
|
+
>
|
|
239
|
+
{t('common.save')}
|
|
240
|
+
</Button>
|
|
241
|
+
{/snippet}
|
|
242
|
+
</Dialog>
|
|
243
|
+
|
|
244
|
+
<Dialog
|
|
245
|
+
open={confirming !== null}
|
|
246
|
+
title={t('view_delete_title', { name: confirming?.name ?? '' })}
|
|
247
|
+
size="sm"
|
|
248
|
+
onOpenChange={(next: boolean) => {
|
|
249
|
+
if (!next) confirming = null
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
252
|
+
<p class="ebody">{t('view_delete_body')}</p>
|
|
253
|
+
|
|
254
|
+
{#snippet footer()}
|
|
255
|
+
<Button variant="ghost" size="sm" onclick={() => (confirming = null)}>{t('common.cancel')}</Button>
|
|
256
|
+
<Button
|
|
257
|
+
size="sm"
|
|
258
|
+
variant="danger"
|
|
259
|
+
loading={remove.isPending}
|
|
260
|
+
onclick={() => confirming && remove.mutate(confirming.id)}
|
|
261
|
+
data-testid="view-delete-confirm"
|
|
262
|
+
>
|
|
263
|
+
{t('common.delete')}
|
|
264
|
+
</Button>
|
|
265
|
+
{/snippet}
|
|
266
|
+
</Dialog>
|
|
267
|
+
|
|
268
|
+
<style>
|
|
269
|
+
.egrid {
|
|
270
|
+
display: grid;
|
|
271
|
+
gap: 12px;
|
|
272
|
+
}
|
|
273
|
+
.erow {
|
|
274
|
+
display: grid;
|
|
275
|
+
gap: 4px;
|
|
276
|
+
}
|
|
277
|
+
.elbl {
|
|
278
|
+
font-size: 12px;
|
|
279
|
+
color: var(--kern-ink-550);
|
|
280
|
+
}
|
|
281
|
+
.ebody {
|
|
282
|
+
margin: 0;
|
|
283
|
+
font-size: 13px;
|
|
284
|
+
}
|
|
285
|
+
/* A sidebar has no room for an illustrated empty state; one quiet line says the same thing. */
|
|
286
|
+
.none {
|
|
287
|
+
margin: 2px 10px 6px;
|
|
288
|
+
font-size: 12.5px;
|
|
289
|
+
color: var(--kern-ink-330);
|
|
290
|
+
}
|
|
291
|
+
.loading {
|
|
292
|
+
display: flex;
|
|
293
|
+
flex-direction: column;
|
|
294
|
+
gap: 4px;
|
|
295
|
+
padding: 4px 2px;
|
|
296
|
+
}
|
|
297
|
+
ul {
|
|
298
|
+
list-style: none;
|
|
299
|
+
margin: 0;
|
|
300
|
+
padding: 0;
|
|
301
|
+
display: flex;
|
|
302
|
+
flex-direction: column;
|
|
303
|
+
gap: 1px;
|
|
304
|
+
}
|
|
305
|
+
li {
|
|
306
|
+
display: flex;
|
|
307
|
+
align-items: center;
|
|
308
|
+
gap: 2px;
|
|
309
|
+
}
|
|
310
|
+
.vrow {
|
|
311
|
+
display: flex;
|
|
312
|
+
align-items: center;
|
|
313
|
+
gap: 8px;
|
|
314
|
+
flex: 1;
|
|
315
|
+
min-width: 0;
|
|
316
|
+
height: 34px;
|
|
317
|
+
padding: 0 10px;
|
|
318
|
+
border-radius: var(--kern-r-xl);
|
|
319
|
+
color: var(--kern-ink-650);
|
|
320
|
+
font-size: 13.5px;
|
|
321
|
+
text-decoration: none;
|
|
322
|
+
}
|
|
323
|
+
.vrow:hover {
|
|
324
|
+
background: var(--kern-border);
|
|
325
|
+
}
|
|
326
|
+
.vrow.on {
|
|
327
|
+
background: var(--kern-ink-900);
|
|
328
|
+
color: var(--kern-ink-inverse);
|
|
329
|
+
font-weight: 600;
|
|
330
|
+
}
|
|
331
|
+
.vname {
|
|
332
|
+
overflow: hidden;
|
|
333
|
+
text-overflow: ellipsis;
|
|
334
|
+
white-space: nowrap;
|
|
335
|
+
}
|
|
336
|
+
</style>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { type StatusCategory, statusStyle } from '../index.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The status glyph (DESIGN.md 3.0): a rounded square whose ring colour comes from the workflow
|
|
6
|
+
* category and whose inner block fills as work moves along. Triage is dashed, Done is solid.
|
|
7
|
+
*/
|
|
8
|
+
interface Props {
|
|
9
|
+
category: StatusCategory
|
|
10
|
+
/** status id and name refine the category, so "In review" gets its own colour */
|
|
11
|
+
statusId?: string | null
|
|
12
|
+
name?: string | null
|
|
13
|
+
size?: number
|
|
14
|
+
/** set to expose the status to assistive technology; otherwise the icon is decorative */
|
|
15
|
+
label?: string | null
|
|
16
|
+
}
|
|
17
|
+
let { category, statusId = null, name = null, size = 16, label = null }: Props = $props()
|
|
18
|
+
const style = $derived(statusStyle(category, statusId, name))
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<svg
|
|
22
|
+
width={size}
|
|
23
|
+
height={size}
|
|
24
|
+
viewBox="0 0 16 16"
|
|
25
|
+
fill="none"
|
|
26
|
+
class="ksi"
|
|
27
|
+
role={label ? 'img' : 'presentation'}
|
|
28
|
+
aria-label={label ?? undefined}
|
|
29
|
+
aria-hidden={label ? undefined : 'true'}
|
|
30
|
+
>
|
|
31
|
+
<rect
|
|
32
|
+
x="1.7"
|
|
33
|
+
y="1.7"
|
|
34
|
+
width="12.6"
|
|
35
|
+
height="12.6"
|
|
36
|
+
rx="4"
|
|
37
|
+
fill="none"
|
|
38
|
+
stroke={style.color}
|
|
39
|
+
stroke-width="1.5"
|
|
40
|
+
stroke-dasharray={style.dash ?? undefined}
|
|
41
|
+
/>
|
|
42
|
+
{#if style.fill}<path d={style.fill} fill={style.color} />{/if}
|
|
43
|
+
</svg>
|
|
44
|
+
|
|
45
|
+
<style>
|
|
46
|
+
.ksi {
|
|
47
|
+
flex: none;
|
|
48
|
+
display: block;
|
|
49
|
+
}
|
|
50
|
+
</style>
|