@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,217 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Icon, relativeTime, toast } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
|
|
4
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
5
|
+
import { getTrackerCatalogue } from '../context.svelte.js'
|
|
6
|
+
import { t } from '../i18n.js'
|
|
7
|
+
import type { IssueApproval } from '../index.js'
|
|
8
|
+
import { trackerKeys } from '../query.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Approvals waiting on somebody.
|
|
12
|
+
*
|
|
13
|
+
* `approvals.list` and `approvals.decide` have existed since transitions did, and nobody could see
|
|
14
|
+
* an approval, let alone decide one — so a transition that needed approval simply stopped.
|
|
15
|
+
*
|
|
16
|
+
* Whether *you* may decide is a question only the server can answer: approvers are subjects
|
|
17
|
+
* (a user, a group, a role, the project lead), and expanding them here would duplicate the rule
|
|
18
|
+
* and eventually disagree with it. So the controls are offered and a refusal is reported plainly.
|
|
19
|
+
*/
|
|
20
|
+
interface Props {
|
|
21
|
+
workspaceId: string
|
|
22
|
+
issueId: string
|
|
23
|
+
/** invalidated when a decision applies the transition and moves the issue */
|
|
24
|
+
oninvalidate: () => void
|
|
25
|
+
}
|
|
26
|
+
let { workspaceId, issueId, oninvalidate }: Props = $props()
|
|
27
|
+
|
|
28
|
+
const api = getTrackerApi()
|
|
29
|
+
const cat = getTrackerCatalogue()
|
|
30
|
+
const queryClient = useQueryClient()
|
|
31
|
+
|
|
32
|
+
let comment = $state('')
|
|
33
|
+
let deciding = $state<string | null>(null)
|
|
34
|
+
|
|
35
|
+
const approvalsQuery = createQuery(() => ({
|
|
36
|
+
queryKey: trackerKeys.approvals(workspaceId, issueId),
|
|
37
|
+
queryFn: () => api.issues.approvals.list({ workspaceId, issueId }),
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
/** Only pending ones need a decision; a settled approval is history, and history has its own feed. */
|
|
41
|
+
const pending = $derived((approvalsQuery.data ?? []).filter((a) => a.state.status === 'pending'))
|
|
42
|
+
|
|
43
|
+
const decide = createMutation(() => ({
|
|
44
|
+
mutationFn: (input: { transitionId: string; decision: 'approve' | 'reject' }) =>
|
|
45
|
+
api.issues.approvals.decide({
|
|
46
|
+
workspaceId,
|
|
47
|
+
issueId,
|
|
48
|
+
transitionId: input.transitionId,
|
|
49
|
+
decision: input.decision,
|
|
50
|
+
...(comment.trim() ? { comment: comment.trim() } : {}),
|
|
51
|
+
}),
|
|
52
|
+
onSuccess: () => {
|
|
53
|
+
comment = ''
|
|
54
|
+
deciding = null
|
|
55
|
+
void queryClient.invalidateQueries({ queryKey: trackerKeys.approvals(workspaceId, issueId) })
|
|
56
|
+
oninvalidate()
|
|
57
|
+
},
|
|
58
|
+
onError: (error: Error) => toast.error(error.message),
|
|
59
|
+
}))
|
|
60
|
+
|
|
61
|
+
const approvedCount = (approval: IssueApproval) =>
|
|
62
|
+
approval.state.decisions.filter((d) => d.decision === 'approve').length
|
|
63
|
+
const name = (userId: string | null) => (userId ? (cat.person(userId)?.name ?? userId) : '')
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
{#if pending.length}
|
|
67
|
+
<section class="approvals" data-testid="approvals">
|
|
68
|
+
<span class="kern-sublabel">{t('approvals')}</span>
|
|
69
|
+
{#each pending as approval (approval.id)}
|
|
70
|
+
<div class="card">
|
|
71
|
+
<div class="chead">
|
|
72
|
+
<Icon name="shield-check" size={14} strokeWidth={1.8} />
|
|
73
|
+
<span class="what">{t('approval_needed', { transition: approval.state.transitionId })}</span>
|
|
74
|
+
</div>
|
|
75
|
+
<p class="meta">
|
|
76
|
+
{t('approval_progress', {
|
|
77
|
+
approved: approvedCount(approval),
|
|
78
|
+
needed: approval.state.spec.minApprovals,
|
|
79
|
+
})}
|
|
80
|
+
·
|
|
81
|
+
{t('approval_requested', {
|
|
82
|
+
who: name(approval.state.requestedBy),
|
|
83
|
+
when: relativeTime(approval.state.requestedAt),
|
|
84
|
+
})}
|
|
85
|
+
</p>
|
|
86
|
+
|
|
87
|
+
{#if approval.state.decisions.length}
|
|
88
|
+
<ul class="decisions">
|
|
89
|
+
{#each approval.state.decisions as decision (decision.userId + decision.at)}
|
|
90
|
+
<li>
|
|
91
|
+
<Icon
|
|
92
|
+
name={decision.decision === 'approve' ? 'check' : 'x'}
|
|
93
|
+
size={12}
|
|
94
|
+
strokeWidth={2}
|
|
95
|
+
/>
|
|
96
|
+
<span>{name(decision.userId)}</span>
|
|
97
|
+
{#if decision.comment}<span class="dcomment">{decision.comment}</span>{/if}
|
|
98
|
+
<time datetime={decision.at}>{relativeTime(decision.at)}</time>
|
|
99
|
+
</li>
|
|
100
|
+
{/each}
|
|
101
|
+
</ul>
|
|
102
|
+
{/if}
|
|
103
|
+
|
|
104
|
+
{#if deciding === approval.id}
|
|
105
|
+
<textarea
|
|
106
|
+
bind:value={comment}
|
|
107
|
+
rows="2"
|
|
108
|
+
placeholder={t('approval_comment')}
|
|
109
|
+
aria-label={t('approval_comment')}
|
|
110
|
+
></textarea>
|
|
111
|
+
{/if}
|
|
112
|
+
|
|
113
|
+
<div class="row">
|
|
114
|
+
<Button
|
|
115
|
+
size="sm"
|
|
116
|
+
disabled={decide.isPending}
|
|
117
|
+
onclick={() => decide.mutate({ transitionId: approval.state.transitionId, decision: 'approve' })}
|
|
118
|
+
>
|
|
119
|
+
{t('approval_approve')}
|
|
120
|
+
</Button>
|
|
121
|
+
<Button
|
|
122
|
+
size="sm"
|
|
123
|
+
variant="ghost"
|
|
124
|
+
disabled={decide.isPending}
|
|
125
|
+
onclick={() => decide.mutate({ transitionId: approval.state.transitionId, decision: 'reject' })}
|
|
126
|
+
>
|
|
127
|
+
{t('approval_reject')}
|
|
128
|
+
</Button>
|
|
129
|
+
{#if deciding !== approval.id}
|
|
130
|
+
<button type="button" class="addnote" onclick={() => (deciding = approval.id)}>
|
|
131
|
+
{t('approval_add_comment')}
|
|
132
|
+
</button>
|
|
133
|
+
{/if}
|
|
134
|
+
</div>
|
|
135
|
+
<p class="who">{t('approval_who')}</p>
|
|
136
|
+
</div>
|
|
137
|
+
{/each}
|
|
138
|
+
</section>
|
|
139
|
+
{/if}
|
|
140
|
+
|
|
141
|
+
<style>
|
|
142
|
+
.approvals {
|
|
143
|
+
display: block;
|
|
144
|
+
margin-top: 16px;
|
|
145
|
+
}
|
|
146
|
+
.card {
|
|
147
|
+
margin-top: 6px;
|
|
148
|
+
padding: 10px;
|
|
149
|
+
border: 1px solid var(--kern-border);
|
|
150
|
+
border-radius: var(--kern-r-sm);
|
|
151
|
+
background: var(--kern-shell);
|
|
152
|
+
}
|
|
153
|
+
.chead {
|
|
154
|
+
display: flex;
|
|
155
|
+
align-items: center;
|
|
156
|
+
gap: 6px;
|
|
157
|
+
font-size: 13px;
|
|
158
|
+
font-weight: 600;
|
|
159
|
+
}
|
|
160
|
+
.meta,
|
|
161
|
+
.who {
|
|
162
|
+
margin: 4px 0 0;
|
|
163
|
+
font-size: 12px;
|
|
164
|
+
color: var(--kern-ink-350);
|
|
165
|
+
}
|
|
166
|
+
.decisions {
|
|
167
|
+
list-style: none;
|
|
168
|
+
margin: 6px 0 0;
|
|
169
|
+
padding: 0;
|
|
170
|
+
display: flex;
|
|
171
|
+
flex-direction: column;
|
|
172
|
+
gap: 3px;
|
|
173
|
+
}
|
|
174
|
+
.decisions li {
|
|
175
|
+
display: flex;
|
|
176
|
+
align-items: center;
|
|
177
|
+
gap: 5px;
|
|
178
|
+
font-size: 12px;
|
|
179
|
+
}
|
|
180
|
+
.dcomment {
|
|
181
|
+
color: var(--kern-ink-350);
|
|
182
|
+
}
|
|
183
|
+
textarea {
|
|
184
|
+
width: 100%;
|
|
185
|
+
margin-top: 6px;
|
|
186
|
+
padding: 6px 8px;
|
|
187
|
+
border: 1px solid var(--kern-border);
|
|
188
|
+
border-radius: var(--kern-r-sm);
|
|
189
|
+
background: var(--kern-surface);
|
|
190
|
+
color: inherit;
|
|
191
|
+
font: inherit;
|
|
192
|
+
font-size: 13px;
|
|
193
|
+
resize: vertical;
|
|
194
|
+
}
|
|
195
|
+
textarea:focus-visible {
|
|
196
|
+
outline: none;
|
|
197
|
+
border-color: var(--kern-accent);
|
|
198
|
+
box-shadow: none;
|
|
199
|
+
}
|
|
200
|
+
.row {
|
|
201
|
+
display: flex;
|
|
202
|
+
align-items: center;
|
|
203
|
+
gap: 6px;
|
|
204
|
+
margin-top: 8px;
|
|
205
|
+
}
|
|
206
|
+
.addnote {
|
|
207
|
+
border: 0;
|
|
208
|
+
background: none;
|
|
209
|
+
color: var(--kern-ink-350);
|
|
210
|
+
font: inherit;
|
|
211
|
+
font-size: 12px;
|
|
212
|
+
cursor: pointer;
|
|
213
|
+
}
|
|
214
|
+
.addnote:hover {
|
|
215
|
+
color: var(--kern-ink-900);
|
|
216
|
+
}
|
|
217
|
+
</style>
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { DropdownMenu, Icon, IconButton, type MenuItem, 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 { Issue, RelationType, StatusCategory } from '../index.js'
|
|
7
|
+
import { trackerKeys } from '../query.js'
|
|
8
|
+
import IssuePicker from './IssuePicker.svelte'
|
|
9
|
+
import StatusIcon from './StatusIcon.svelte'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What an issue is connected to: its children, other issues, and pages elsewhere.
|
|
13
|
+
*
|
|
14
|
+
* Three servers with no interface until now. They share one shape — list, add, remove — so they
|
|
15
|
+
* share one component rather than three that drift apart.
|
|
16
|
+
*/
|
|
17
|
+
interface Props {
|
|
18
|
+
workspaceId: string
|
|
19
|
+
issue: Issue
|
|
20
|
+
canEdit: boolean
|
|
21
|
+
onopen: (key: string) => void
|
|
22
|
+
}
|
|
23
|
+
let { workspaceId, issue, canEdit, onopen }: Props = $props()
|
|
24
|
+
|
|
25
|
+
const api = getTrackerApi()
|
|
26
|
+
const queryClient = useQueryClient()
|
|
27
|
+
|
|
28
|
+
type Adding = 'child' | 'relation' | 'link' | null
|
|
29
|
+
let adding = $state<Adding>(null)
|
|
30
|
+
let relationType = $state<RelationType>('blocks')
|
|
31
|
+
let linkUrl = $state('')
|
|
32
|
+
let linkTitle = $state('')
|
|
33
|
+
|
|
34
|
+
const RELATION_LABELS: Record<RelationType, () => string> = {
|
|
35
|
+
blocks: () => t('relation_blocks'),
|
|
36
|
+
blocked_by: () => t('relation_blocked_by'),
|
|
37
|
+
relates: () => t('relation_relates'),
|
|
38
|
+
duplicates: () => t('relation_duplicates'),
|
|
39
|
+
duplicated_by: () => t('relation_duplicated_by'),
|
|
40
|
+
clones: () => t('relation_clones'),
|
|
41
|
+
cloned_by: () => t('relation_cloned_by'),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const relationsQuery = createQuery(() => ({
|
|
45
|
+
queryKey: trackerKeys.relations(workspaceId, issue.id),
|
|
46
|
+
queryFn: () => api.issues.relations.list({ workspaceId, issueId: issue.id }),
|
|
47
|
+
}))
|
|
48
|
+
const linksQuery = createQuery(() => ({
|
|
49
|
+
queryKey: trackerKeys.links(workspaceId, issue.id),
|
|
50
|
+
queryFn: () => api.issues.links.list({ workspaceId, issueId: issue.id }),
|
|
51
|
+
}))
|
|
52
|
+
const childrenQuery = createQuery(() => ({
|
|
53
|
+
queryKey: trackerKeys.children(workspaceId, issue.id),
|
|
54
|
+
queryFn: () =>
|
|
55
|
+
api.issues.query({
|
|
56
|
+
workspaceId,
|
|
57
|
+
kql: `parent = "${issue.key}"`,
|
|
58
|
+
limit: 100,
|
|
59
|
+
includeArchived: false,
|
|
60
|
+
include: { total: false, groupCounts: false, full: false },
|
|
61
|
+
}),
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
const relations = $derived(relationsQuery.data ?? [])
|
|
65
|
+
const links = $derived(linksQuery.data ?? [])
|
|
66
|
+
const children = $derived(childrenQuery.data?.items ?? [])
|
|
67
|
+
/** Never offer an issue that is already connected, nor the issue itself. */
|
|
68
|
+
const connectedIds = $derived([
|
|
69
|
+
issue.id,
|
|
70
|
+
...(issue.parentId ? [issue.parentId] : []),
|
|
71
|
+
...relations.map((r) => r.issue.id),
|
|
72
|
+
...children.map((c) => c.id),
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
const invalidate = (keys: readonly (readonly unknown[])[]) => {
|
|
76
|
+
for (const queryKey of keys) void queryClient.invalidateQueries({ queryKey })
|
|
77
|
+
}
|
|
78
|
+
const fail = (error: Error) => toast.error(error.message)
|
|
79
|
+
|
|
80
|
+
const addRelation = createMutation(() => ({
|
|
81
|
+
mutationFn: (targetIssueId: string) =>
|
|
82
|
+
api.issues.relations.create({ workspaceId, issueId: issue.id, type: relationType, targetIssueId }),
|
|
83
|
+
onSuccess: () => {
|
|
84
|
+
adding = null
|
|
85
|
+
invalidate([trackerKeys.relations(workspaceId, issue.id), trackerKeys.issue(workspaceId, issue.id)])
|
|
86
|
+
},
|
|
87
|
+
onError: fail,
|
|
88
|
+
}))
|
|
89
|
+
|
|
90
|
+
const removeRelation = createMutation(() => ({
|
|
91
|
+
mutationFn: (relationId: string) => api.issues.relations.delete({ workspaceId, relationId }),
|
|
92
|
+
onSuccess: () =>
|
|
93
|
+
invalidate([trackerKeys.relations(workspaceId, issue.id), trackerKeys.issue(workspaceId, issue.id)]),
|
|
94
|
+
onError: fail,
|
|
95
|
+
}))
|
|
96
|
+
|
|
97
|
+
const addChild = createMutation(() => ({
|
|
98
|
+
mutationFn: (childId: string) =>
|
|
99
|
+
api.issues.update({ workspaceId, issueId: childId, patch: { parentId: issue.id } }),
|
|
100
|
+
onSuccess: () => {
|
|
101
|
+
adding = null
|
|
102
|
+
invalidate([trackerKeys.children(workspaceId, issue.id)])
|
|
103
|
+
},
|
|
104
|
+
onError: fail,
|
|
105
|
+
}))
|
|
106
|
+
|
|
107
|
+
const removeChild = createMutation(() => ({
|
|
108
|
+
mutationFn: (childId: string) =>
|
|
109
|
+
api.issues.update({ workspaceId, issueId: childId, patch: { parentId: null } }),
|
|
110
|
+
onSuccess: () => invalidate([trackerKeys.children(workspaceId, issue.id)]),
|
|
111
|
+
onError: fail,
|
|
112
|
+
}))
|
|
113
|
+
|
|
114
|
+
const addLink = createMutation(() => ({
|
|
115
|
+
mutationFn: () =>
|
|
116
|
+
api.issues.links.add({
|
|
117
|
+
workspaceId,
|
|
118
|
+
issueId: issue.id,
|
|
119
|
+
url: linkUrl.trim(),
|
|
120
|
+
...(linkTitle.trim() ? { title: linkTitle.trim() } : {}),
|
|
121
|
+
kind: 'generic',
|
|
122
|
+
}),
|
|
123
|
+
onSuccess: () => {
|
|
124
|
+
adding = null
|
|
125
|
+
linkUrl = ''
|
|
126
|
+
linkTitle = ''
|
|
127
|
+
invalidate([trackerKeys.links(workspaceId, issue.id)])
|
|
128
|
+
},
|
|
129
|
+
onError: fail,
|
|
130
|
+
}))
|
|
131
|
+
|
|
132
|
+
const removeLink = createMutation(() => ({
|
|
133
|
+
mutationFn: (linkId: string) => api.issues.links.remove({ workspaceId, linkId }),
|
|
134
|
+
onSuccess: () => invalidate([trackerKeys.links(workspaceId, issue.id)]),
|
|
135
|
+
onError: fail,
|
|
136
|
+
}))
|
|
137
|
+
|
|
138
|
+
const relationMenu = $derived<MenuItem[]>(
|
|
139
|
+
(Object.keys(RELATION_LABELS) as RelationType[]).map((type) => ({
|
|
140
|
+
type: 'item' as const,
|
|
141
|
+
id: type,
|
|
142
|
+
label: RELATION_LABELS[type](),
|
|
143
|
+
onSelect: () => {
|
|
144
|
+
relationType = type
|
|
145
|
+
adding = 'relation'
|
|
146
|
+
},
|
|
147
|
+
})),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
/** Relations read better grouped by kind than as one undifferentiated list. */
|
|
151
|
+
const relationGroups = $derived.by(() => {
|
|
152
|
+
const groups = new Map<RelationType, typeof relations>()
|
|
153
|
+
for (const relation of relations)
|
|
154
|
+
groups.set(relation.type, [...(groups.get(relation.type) ?? []), relation])
|
|
155
|
+
return [...groups.entries()]
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const isHttp = $derived(/^https?:\/\/\S+$/i.test(linkUrl.trim()))
|
|
159
|
+
</script>
|
|
160
|
+
|
|
161
|
+
{#snippet issueLine(
|
|
162
|
+
key: string,
|
|
163
|
+
title: string,
|
|
164
|
+
statusId: string,
|
|
165
|
+
category: StatusCategory,
|
|
166
|
+
remove?: () => void,
|
|
167
|
+
)}
|
|
168
|
+
<li>
|
|
169
|
+
<button type="button" class="line" onclick={() => onopen(key)}>
|
|
170
|
+
<StatusIcon {category} {statusId} size={13} />
|
|
171
|
+
<span class="ikey">{key}</span>
|
|
172
|
+
<span class="ititle">{title}</span>
|
|
173
|
+
</button>
|
|
174
|
+
{#if remove && canEdit}
|
|
175
|
+
<IconButton icon="x" size={22} label={t('connection_remove')} onclick={remove} />
|
|
176
|
+
{/if}
|
|
177
|
+
</li>
|
|
178
|
+
{/snippet}
|
|
179
|
+
|
|
180
|
+
<section class="connections">
|
|
181
|
+
<!-- sub-issues -->
|
|
182
|
+
<div class="block">
|
|
183
|
+
<div class="bhead">
|
|
184
|
+
<span class="kern-sublabel">{t('subissues')}</span>
|
|
185
|
+
{#if canEdit}
|
|
186
|
+
<IconButton
|
|
187
|
+
icon="plus"
|
|
188
|
+
size={22}
|
|
189
|
+
label={t('subissue_add')}
|
|
190
|
+
onclick={() => (adding = adding === 'child' ? null : 'child')}
|
|
191
|
+
/>
|
|
192
|
+
{/if}
|
|
193
|
+
</div>
|
|
194
|
+
{#if adding === 'child'}
|
|
195
|
+
<IssuePicker
|
|
196
|
+
{workspaceId}
|
|
197
|
+
projectId={issue.projectId}
|
|
198
|
+
exclude={connectedIds}
|
|
199
|
+
placeholder={t('subissue_add')}
|
|
200
|
+
onpick={(picked) => addChild.mutate(picked.id)}
|
|
201
|
+
oncancel={() => (adding = null)}
|
|
202
|
+
/>
|
|
203
|
+
{/if}
|
|
204
|
+
{#if children.length}
|
|
205
|
+
<ul>
|
|
206
|
+
{#each children as child (child.id)}
|
|
207
|
+
{@render issueLine(child.key, child.title, child.statusId, child.statusCategory, () =>
|
|
208
|
+
removeChild.mutate(child.id),
|
|
209
|
+
)}
|
|
210
|
+
{/each}
|
|
211
|
+
</ul>
|
|
212
|
+
{:else if adding !== 'child'}
|
|
213
|
+
<p class="empty">{t('subissues_empty')}</p>
|
|
214
|
+
{/if}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<!-- relations -->
|
|
218
|
+
<div class="block">
|
|
219
|
+
<div class="bhead">
|
|
220
|
+
<span class="kern-sublabel">{t('relations')}</span>
|
|
221
|
+
{#if canEdit}
|
|
222
|
+
<DropdownMenu items={relationMenu} align="end">
|
|
223
|
+
{#snippet trigger(props)}
|
|
224
|
+
<IconButton {...props} icon="plus" size={22} label={t('relation_add')} />
|
|
225
|
+
{/snippet}
|
|
226
|
+
</DropdownMenu>
|
|
227
|
+
{/if}
|
|
228
|
+
</div>
|
|
229
|
+
{#if adding === 'relation'}
|
|
230
|
+
<p class="picking">{RELATION_LABELS[relationType]()}</p>
|
|
231
|
+
<IssuePicker
|
|
232
|
+
{workspaceId}
|
|
233
|
+
exclude={connectedIds}
|
|
234
|
+
placeholder={t('relation_add')}
|
|
235
|
+
onpick={(picked) => addRelation.mutate(picked.id)}
|
|
236
|
+
oncancel={() => (adding = null)}
|
|
237
|
+
/>
|
|
238
|
+
{/if}
|
|
239
|
+
{#if relationGroups.length}
|
|
240
|
+
{#each relationGroups as [type, group] (type)}
|
|
241
|
+
<p class="gname">{RELATION_LABELS[type]()}</p>
|
|
242
|
+
<ul>
|
|
243
|
+
{#each group as relation (relation.id)}
|
|
244
|
+
{@render issueLine(
|
|
245
|
+
relation.issue.key,
|
|
246
|
+
relation.issue.title,
|
|
247
|
+
relation.issue.statusId,
|
|
248
|
+
relation.issue.statusCategory,
|
|
249
|
+
() => removeRelation.mutate(relation.id),
|
|
250
|
+
)}
|
|
251
|
+
{/each}
|
|
252
|
+
</ul>
|
|
253
|
+
{/each}
|
|
254
|
+
{:else if adding !== 'relation'}
|
|
255
|
+
<p class="empty">{t('relations_empty')}</p>
|
|
256
|
+
{/if}
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<!-- external links -->
|
|
260
|
+
<div class="block">
|
|
261
|
+
<div class="bhead">
|
|
262
|
+
<span class="kern-sublabel">{t('links')}</span>
|
|
263
|
+
{#if canEdit}
|
|
264
|
+
<IconButton
|
|
265
|
+
icon="link"
|
|
266
|
+
size={22}
|
|
267
|
+
label={t('link_add')}
|
|
268
|
+
onclick={() => (adding = adding === 'link' ? null : 'link')}
|
|
269
|
+
/>
|
|
270
|
+
{/if}
|
|
271
|
+
</div>
|
|
272
|
+
{#if adding === 'link'}
|
|
273
|
+
<form
|
|
274
|
+
class="linkform"
|
|
275
|
+
onsubmit={(e) => {
|
|
276
|
+
e.preventDefault()
|
|
277
|
+
if (isHttp) addLink.mutate()
|
|
278
|
+
}}
|
|
279
|
+
>
|
|
280
|
+
<input
|
|
281
|
+
bind:value={linkUrl}
|
|
282
|
+
type="url"
|
|
283
|
+
placeholder="https://"
|
|
284
|
+
aria-label={t('link_url')}
|
|
285
|
+
data-testid="link-url"
|
|
286
|
+
/>
|
|
287
|
+
<input bind:value={linkTitle} type="text" placeholder={t('link_title')} aria-label={t('link_title')} />
|
|
288
|
+
<div class="row">
|
|
289
|
+
<button type="submit" disabled={!isHttp}>{t('common.add')}</button>
|
|
290
|
+
<button type="button" onclick={() => (adding = null)}>{t('common.cancel')}</button>
|
|
291
|
+
</div>
|
|
292
|
+
</form>
|
|
293
|
+
{/if}
|
|
294
|
+
{#if links.length}
|
|
295
|
+
<ul>
|
|
296
|
+
{#each links as link (link.id)}
|
|
297
|
+
<li>
|
|
298
|
+
<a class="line" href={link.url} target="_blank" rel="noreferrer noopener">
|
|
299
|
+
<Icon name="external-link" size={13} strokeWidth={1.8} />
|
|
300
|
+
<span class="ititle">{link.title || link.url}</span>
|
|
301
|
+
</a>
|
|
302
|
+
{#if canEdit}
|
|
303
|
+
<IconButton
|
|
304
|
+
icon="x"
|
|
305
|
+
size={22}
|
|
306
|
+
label={t('connection_remove')}
|
|
307
|
+
onclick={() => removeLink.mutate(link.id)}
|
|
308
|
+
/>
|
|
309
|
+
{/if}
|
|
310
|
+
</li>
|
|
311
|
+
{/each}
|
|
312
|
+
</ul>
|
|
313
|
+
{:else if adding !== 'link'}
|
|
314
|
+
<p class="empty">{t('links_empty')}</p>
|
|
315
|
+
{/if}
|
|
316
|
+
</div>
|
|
317
|
+
</section>
|
|
318
|
+
|
|
319
|
+
<style>
|
|
320
|
+
.connections {
|
|
321
|
+
display: flex;
|
|
322
|
+
flex-direction: column;
|
|
323
|
+
gap: 14px;
|
|
324
|
+
margin-top: 16px;
|
|
325
|
+
}
|
|
326
|
+
.bhead {
|
|
327
|
+
display: flex;
|
|
328
|
+
align-items: center;
|
|
329
|
+
justify-content: space-between;
|
|
330
|
+
}
|
|
331
|
+
ul {
|
|
332
|
+
list-style: none;
|
|
333
|
+
margin: 4px 0 0;
|
|
334
|
+
padding: 0;
|
|
335
|
+
display: flex;
|
|
336
|
+
flex-direction: column;
|
|
337
|
+
gap: 2px;
|
|
338
|
+
}
|
|
339
|
+
li {
|
|
340
|
+
display: flex;
|
|
341
|
+
align-items: center;
|
|
342
|
+
gap: 4px;
|
|
343
|
+
}
|
|
344
|
+
.line {
|
|
345
|
+
display: flex;
|
|
346
|
+
align-items: center;
|
|
347
|
+
gap: 6px;
|
|
348
|
+
flex: 1;
|
|
349
|
+
min-width: 0;
|
|
350
|
+
padding: 3px 6px;
|
|
351
|
+
margin-inline-start: -6px;
|
|
352
|
+
border: 0;
|
|
353
|
+
border-radius: var(--kern-r-sm);
|
|
354
|
+
background: none;
|
|
355
|
+
color: inherit;
|
|
356
|
+
font: inherit;
|
|
357
|
+
font-size: 13px;
|
|
358
|
+
text-align: start;
|
|
359
|
+
text-decoration: none;
|
|
360
|
+
cursor: pointer;
|
|
361
|
+
}
|
|
362
|
+
.line:hover {
|
|
363
|
+
background: var(--kern-surface-active);
|
|
364
|
+
}
|
|
365
|
+
.ikey {
|
|
366
|
+
font-family: var(--kern-font-mono);
|
|
367
|
+
font-size: 12px;
|
|
368
|
+
color: var(--kern-ink-350);
|
|
369
|
+
}
|
|
370
|
+
.ititle {
|
|
371
|
+
overflow: hidden;
|
|
372
|
+
text-overflow: ellipsis;
|
|
373
|
+
white-space: nowrap;
|
|
374
|
+
}
|
|
375
|
+
.gname,
|
|
376
|
+
.picking {
|
|
377
|
+
margin: 6px 0 0;
|
|
378
|
+
font-size: 12px;
|
|
379
|
+
color: var(--kern-ink-350);
|
|
380
|
+
}
|
|
381
|
+
.empty {
|
|
382
|
+
margin: 4px 0 0;
|
|
383
|
+
font-size: 13px;
|
|
384
|
+
color: var(--kern-ink-350);
|
|
385
|
+
}
|
|
386
|
+
.linkform {
|
|
387
|
+
display: flex;
|
|
388
|
+
flex-direction: column;
|
|
389
|
+
gap: 6px;
|
|
390
|
+
margin-top: 6px;
|
|
391
|
+
}
|
|
392
|
+
.linkform input {
|
|
393
|
+
padding: 5px 8px;
|
|
394
|
+
border: 1px solid var(--kern-border);
|
|
395
|
+
border-radius: var(--kern-r-sm);
|
|
396
|
+
background: var(--kern-surface);
|
|
397
|
+
color: inherit;
|
|
398
|
+
font: inherit;
|
|
399
|
+
font-size: 13px;
|
|
400
|
+
}
|
|
401
|
+
.linkform input:focus-visible {
|
|
402
|
+
outline: none;
|
|
403
|
+
border-color: var(--kern-accent);
|
|
404
|
+
box-shadow: none;
|
|
405
|
+
}
|
|
406
|
+
.row {
|
|
407
|
+
display: flex;
|
|
408
|
+
gap: 6px;
|
|
409
|
+
}
|
|
410
|
+
.row button {
|
|
411
|
+
padding: 3px 10px;
|
|
412
|
+
border: 1px solid var(--kern-border);
|
|
413
|
+
border-radius: var(--kern-r-sm);
|
|
414
|
+
background: var(--kern-surface);
|
|
415
|
+
color: inherit;
|
|
416
|
+
font: inherit;
|
|
417
|
+
font-size: 12px;
|
|
418
|
+
cursor: pointer;
|
|
419
|
+
}
|
|
420
|
+
.row button:disabled {
|
|
421
|
+
opacity: 0.5;
|
|
422
|
+
cursor: default;
|
|
423
|
+
}
|
|
424
|
+
</style>
|