@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,410 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
messageLocale,
|
|
6
|
+
navigation,
|
|
7
|
+
ProgressBar,
|
|
8
|
+
Select,
|
|
9
|
+
SettingsPage,
|
|
10
|
+
SettingsSection,
|
|
11
|
+
Spinner,
|
|
12
|
+
session,
|
|
13
|
+
toast,
|
|
14
|
+
uploadFile,
|
|
15
|
+
} from '@kernhq/ui'
|
|
16
|
+
import { createMutation, createQuery } from '@tanstack/svelte-query'
|
|
17
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
18
|
+
import { previewCsv } from '../csv.js'
|
|
19
|
+
import { t } from '../i18n.js'
|
|
20
|
+
import type { ImportJob } from '../index.js'
|
|
21
|
+
import { canTracker } from '../permissions.js'
|
|
22
|
+
import { trackerKeys } from '../query.js'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Bringing work in from somewhere else.
|
|
26
|
+
*
|
|
27
|
+
* The columns are read in the browser before anything is uploaded, so the mapping screen offers the
|
|
28
|
+
* file's real names and a row of its real values. Asking somebody to map "column 3" turns a
|
|
29
|
+
* five-minute import into a spreadsheet-counting exercise.
|
|
30
|
+
*
|
|
31
|
+
* Nothing is imported until the mapping is confirmed. An import that guesses is an import somebody
|
|
32
|
+
* has to undo, and there is no undo.
|
|
33
|
+
*/
|
|
34
|
+
const api = getTrackerApi()
|
|
35
|
+
|
|
36
|
+
const slug = $derived(navigation.workspaceSlug)
|
|
37
|
+
const workspaceId = $derived(session.workspaces.find((w) => w.slug === slug)?.id ?? '')
|
|
38
|
+
const canManage = $derived(canTracker('projectManage'))
|
|
39
|
+
|
|
40
|
+
/** `''` means "do not import this column", which is the safe default for every one of them. */
|
|
41
|
+
const DESTINATIONS = [
|
|
42
|
+
{ value: '', label: () => t('import_skip') },
|
|
43
|
+
{ value: 'title', label: () => t('import_field_title') },
|
|
44
|
+
{ value: 'description', label: () => t('import_field_description') },
|
|
45
|
+
{ value: 'status', label: () => t('import_field_status') },
|
|
46
|
+
{ value: 'priority', label: () => t('import_field_priority') },
|
|
47
|
+
{ value: 'assignee', label: () => t('import_field_assignee') },
|
|
48
|
+
{ value: 'labels', label: () => t('import_field_labels') },
|
|
49
|
+
{ value: 'due', label: () => t('import_field_due') },
|
|
50
|
+
{ value: 'estimate', label: () => t('import_field_estimate') },
|
|
51
|
+
{ value: 'key', label: () => t('import_field_key') },
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
let selectedProject = $state<string | null>(null)
|
|
55
|
+
let file = $state<File | null>(null)
|
|
56
|
+
let preview = $state<{ columns: string[]; rows: string[][] }>({ columns: [], rows: [] })
|
|
57
|
+
let mapping = $state<Record<string, string>>({})
|
|
58
|
+
let hasHeader = $state(true)
|
|
59
|
+
let jobId = $state<string | null>(null)
|
|
60
|
+
|
|
61
|
+
const projectsQuery = createQuery(() => ({
|
|
62
|
+
queryKey: trackerKeys.projects(workspaceId),
|
|
63
|
+
queryFn: () => api.projects.list({ workspaceId }),
|
|
64
|
+
enabled: Boolean(workspaceId),
|
|
65
|
+
}))
|
|
66
|
+
const projects = $derived(projectsQuery.data ?? [])
|
|
67
|
+
const projectId = $derived(selectedProject ?? projects[0]?.id ?? '')
|
|
68
|
+
|
|
69
|
+
const fieldsQuery = createQuery(() => ({
|
|
70
|
+
queryKey: trackerKeys.fields(workspaceId),
|
|
71
|
+
queryFn: () => api.fields.list({ workspaceId, includeArchived: false }),
|
|
72
|
+
enabled: Boolean(workspaceId),
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
/** A column can go to a built-in field or to any custom field, by the same `cf.<key>` name KQL uses. */
|
|
76
|
+
const destinations = $derived([
|
|
77
|
+
...DESTINATIONS.map((d) => ({ value: d.value, label: d.label() })),
|
|
78
|
+
...(fieldsQuery.data ?? []).map((f) => ({ value: `cf.${f.key}`, label: f.name })),
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
/** Polls only while something is running: a finished import has nothing more to say. */
|
|
82
|
+
const jobQuery = createQuery(() => ({
|
|
83
|
+
queryKey: [...trackerKeys.projects(workspaceId), 'import', jobId ?? ''],
|
|
84
|
+
queryFn: () => api.imports.get({ workspaceId, id: jobId as string }),
|
|
85
|
+
enabled: Boolean(jobId),
|
|
86
|
+
refetchInterval: (query: { state: { data?: ImportJob } }) =>
|
|
87
|
+
query.state.data && ['completed', 'failed', 'cancelled'].includes(query.state.data.status) ? false : 1500,
|
|
88
|
+
}))
|
|
89
|
+
const job = $derived((jobQuery.data ?? null) as ImportJob | null)
|
|
90
|
+
const running = $derived(job ? ['pending', 'running'].includes(job.status) : false)
|
|
91
|
+
|
|
92
|
+
async function chooseFile(list: FileList | null) {
|
|
93
|
+
const chosen = list?.[0]
|
|
94
|
+
if (!chosen) return
|
|
95
|
+
file = chosen
|
|
96
|
+
jobId = null
|
|
97
|
+
const text = await chosen.text()
|
|
98
|
+
preview = previewCsv(text, { hasHeader })
|
|
99
|
+
// Nothing is mapped to begin with. A guess that lands a "Notes" column in the title is worse
|
|
100
|
+
// than an empty form, because nobody re-reads a field that already looks filled in.
|
|
101
|
+
mapping = {}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const start = createMutation(() => ({
|
|
105
|
+
mutationFn: async () => {
|
|
106
|
+
const uploaded = await uploadFile({
|
|
107
|
+
workspaceId,
|
|
108
|
+
file: file as File,
|
|
109
|
+
name: (file as File).name,
|
|
110
|
+
mimeType: 'text/csv',
|
|
111
|
+
})
|
|
112
|
+
const columns: Record<string, string> = {}
|
|
113
|
+
for (const [column, destination] of Object.entries(mapping))
|
|
114
|
+
if (destination) columns[column] = destination
|
|
115
|
+
return api.imports.start({
|
|
116
|
+
workspaceId,
|
|
117
|
+
projectId,
|
|
118
|
+
source: 'csv',
|
|
119
|
+
fileId: uploaded.id,
|
|
120
|
+
mapping: { columns, hasHeader, delimiter: ',' },
|
|
121
|
+
} as never)
|
|
122
|
+
},
|
|
123
|
+
onSuccess: (created: ImportJob) => {
|
|
124
|
+
jobId = created.id
|
|
125
|
+
},
|
|
126
|
+
onError: (error: Error) => toast.error(error.message),
|
|
127
|
+
}))
|
|
128
|
+
|
|
129
|
+
const cancel = createMutation(() => ({
|
|
130
|
+
mutationFn: () => api.imports.cancel({ workspaceId, id: jobId as string }),
|
|
131
|
+
onError: (error: Error) => toast.error(error.message),
|
|
132
|
+
}))
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* What was imported before.
|
|
136
|
+
*
|
|
137
|
+
* An import is the one thing here that happens to a project rather than in front of you: it runs,
|
|
138
|
+
* you navigate away, and afterwards the only question is what it did. `imports.list` answered that
|
|
139
|
+
* from the start and nothing asked it, so leaving the page lost the outcome for good.
|
|
140
|
+
*/
|
|
141
|
+
const historyQuery = createQuery(() => ({
|
|
142
|
+
queryKey: [...trackerKeys.projects(workspaceId), 'imports', projectId],
|
|
143
|
+
queryFn: () => api.imports.list({ workspaceId, projectId }),
|
|
144
|
+
enabled: Boolean(projectId),
|
|
145
|
+
}))
|
|
146
|
+
/** Newest first: the one somebody is looking for is almost always the last one. */
|
|
147
|
+
const history = $derived(
|
|
148
|
+
[...(historyQuery.data ?? [])].sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const STATUS_LABELS: Record<string, () => string> = {
|
|
152
|
+
pending: () => t('import_pending'),
|
|
153
|
+
running: () => t('import_running'),
|
|
154
|
+
completed: () => t('import_completed'),
|
|
155
|
+
failed: () => t('import_failed'),
|
|
156
|
+
cancelled: () => t('import_cancelled'),
|
|
157
|
+
}
|
|
158
|
+
const statusLabel = (status: string) => STATUS_LABELS[status]?.() ?? status
|
|
159
|
+
const when = new Intl.DateTimeFormat(messageLocale(), { dateStyle: 'medium', timeStyle: 'short' })
|
|
160
|
+
|
|
161
|
+
/** A title is the one thing an issue cannot be created without. */
|
|
162
|
+
const mappedTitle = $derived(Object.values(mapping).includes('title'))
|
|
163
|
+
const canStart = $derived(Boolean(file) && mappedTitle && canManage && !running)
|
|
164
|
+
</script>
|
|
165
|
+
|
|
166
|
+
<SettingsPage title={t('settings_import')} description={t('settings_import_hint')}>
|
|
167
|
+
{#if projectsQuery.isPending}
|
|
168
|
+
<SettingsSection><div class="state"><Spinner /></div></SettingsSection>
|
|
169
|
+
{:else if !projects.length}
|
|
170
|
+
<SettingsSection><p class="state">{t('settings_planning_no_projects')}</p></SettingsSection>
|
|
171
|
+
{:else}
|
|
172
|
+
<SettingsSection title={t('settings_planning_project')}>
|
|
173
|
+
<Select
|
|
174
|
+
value={projectId}
|
|
175
|
+
options={projects.map((p) => ({ value: p.id, label: `${p.key} · ${p.name}` }))}
|
|
176
|
+
onValueChange={(v: string) => (selectedProject = v)}
|
|
177
|
+
/>
|
|
178
|
+
</SettingsSection>
|
|
179
|
+
|
|
180
|
+
<SettingsSection title={t('import_file')} description={t('import_file_hint')}>
|
|
181
|
+
<input
|
|
182
|
+
type="file"
|
|
183
|
+
accept=".csv,text/csv"
|
|
184
|
+
aria-label={t('import_file')}
|
|
185
|
+
data-testid="import-file"
|
|
186
|
+
onchange={(e) => {
|
|
187
|
+
void chooseFile(e.currentTarget.files)
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
<label class="check">
|
|
191
|
+
<input
|
|
192
|
+
type="checkbox"
|
|
193
|
+
checked={hasHeader}
|
|
194
|
+
onchange={async (e) => {
|
|
195
|
+
hasHeader = e.currentTarget.checked
|
|
196
|
+
if (file) preview = previewCsv(await file.text(), { hasHeader })
|
|
197
|
+
}}
|
|
198
|
+
/>
|
|
199
|
+
{t('import_has_header')}
|
|
200
|
+
</label>
|
|
201
|
+
</SettingsSection>
|
|
202
|
+
|
|
203
|
+
{#if preview.columns.length}
|
|
204
|
+
<SettingsSection title={t('import_mapping')} description={t('import_mapping_hint')}>
|
|
205
|
+
<table class="map" data-testid="import-mapping">
|
|
206
|
+
<thead>
|
|
207
|
+
<tr>
|
|
208
|
+
<th scope="col">{t('import_column')}</th>
|
|
209
|
+
<th scope="col">{t('import_example')}</th>
|
|
210
|
+
<th scope="col">{t('import_goes_to')}</th>
|
|
211
|
+
</tr>
|
|
212
|
+
</thead>
|
|
213
|
+
<tbody>
|
|
214
|
+
{#each preview.columns as column, i (column + i)}
|
|
215
|
+
<tr>
|
|
216
|
+
<th scope="row">{column}</th>
|
|
217
|
+
<td class="example">{preview.rows[0]?.[i] ?? ''}</td>
|
|
218
|
+
<td>
|
|
219
|
+
<Select
|
|
220
|
+
value={mapping[column] ?? ''}
|
|
221
|
+
options={destinations}
|
|
222
|
+
onValueChange={(v: string) => (mapping = { ...mapping, [column]: v })}
|
|
223
|
+
/>
|
|
224
|
+
</td>
|
|
225
|
+
</tr>
|
|
226
|
+
{/each}
|
|
227
|
+
</tbody>
|
|
228
|
+
</table>
|
|
229
|
+
|
|
230
|
+
{#if !mappedTitle}
|
|
231
|
+
<p class="warn" data-testid="needs-title">{t('import_needs_title')}</p>
|
|
232
|
+
{/if}
|
|
233
|
+
|
|
234
|
+
{#snippet footer()}
|
|
235
|
+
<Button
|
|
236
|
+
size="sm"
|
|
237
|
+
disabled={!canStart}
|
|
238
|
+
loading={start.isPending}
|
|
239
|
+
onclick={() => start.mutate()}
|
|
240
|
+
data-testid="import-start"
|
|
241
|
+
>
|
|
242
|
+
{t('import_start')}
|
|
243
|
+
</Button>
|
|
244
|
+
{/snippet}
|
|
245
|
+
</SettingsSection>
|
|
246
|
+
{/if}
|
|
247
|
+
|
|
248
|
+
{#if job}
|
|
249
|
+
<SettingsSection title={t('import_progress')}>
|
|
250
|
+
<div class="jrow" data-testid="import-job">
|
|
251
|
+
<Badge tone={job.status === 'failed' ? 'danger' : undefined}>{statusLabel(job.status)}</Badge>
|
|
252
|
+
<span class="counts">
|
|
253
|
+
{t('import_counts', {
|
|
254
|
+
created: job.progress.created,
|
|
255
|
+
processed: job.progress.processed,
|
|
256
|
+
total: job.progress.total,
|
|
257
|
+
failed: job.progress.failed,
|
|
258
|
+
})}
|
|
259
|
+
</span>
|
|
260
|
+
{#if running}
|
|
261
|
+
<Button size="sm" variant="ghost" onclick={() => cancel.mutate()}>{t('common.cancel')}</Button>
|
|
262
|
+
{/if}
|
|
263
|
+
</div>
|
|
264
|
+
<ProgressBar
|
|
265
|
+
value={job.progress.total ? (job.progress.processed / job.progress.total) * 100 : 0}
|
|
266
|
+
/>
|
|
267
|
+
{#if job.errors.length}
|
|
268
|
+
<ul class="errors">
|
|
269
|
+
{#each job.errors.slice(0, 20) as problem, i (problem.message + i)}
|
|
270
|
+
<li>
|
|
271
|
+
{#if problem.row !== null}<span class="row">{t('import_row', { row: problem.row })}</span>{/if}
|
|
272
|
+
{problem.message}
|
|
273
|
+
</li>
|
|
274
|
+
{/each}
|
|
275
|
+
</ul>
|
|
276
|
+
{#if job.errors.length > 20}
|
|
277
|
+
<p class="note">{t('import_more_errors', { count: job.errors.length - 20 })}</p>
|
|
278
|
+
{/if}
|
|
279
|
+
{/if}
|
|
280
|
+
</SettingsSection>
|
|
281
|
+
{/if}
|
|
282
|
+
|
|
283
|
+
{#if history.length}
|
|
284
|
+
<SettingsSection title={t('import_history')} description={t('import_history_hint')}>
|
|
285
|
+
<ul class="history" data-testid="import-history">
|
|
286
|
+
{#each history as past (past.id)}
|
|
287
|
+
<li>
|
|
288
|
+
<button type="button" onclick={() => (jobId = past.id)} data-testid="import-history-row">
|
|
289
|
+
<Badge tone={past.status === 'failed' ? 'danger' : undefined}>
|
|
290
|
+
{statusLabel(past.status)}
|
|
291
|
+
</Badge>
|
|
292
|
+
<span class="hwhen">{when.format(new Date(past.createdAt))}</span>
|
|
293
|
+
<span class="counts">
|
|
294
|
+
{t('import_counts', {
|
|
295
|
+
created: past.progress.created,
|
|
296
|
+
processed: past.progress.processed,
|
|
297
|
+
total: past.progress.total,
|
|
298
|
+
failed: past.progress.failed,
|
|
299
|
+
})}
|
|
300
|
+
</span>
|
|
301
|
+
</button>
|
|
302
|
+
</li>
|
|
303
|
+
{/each}
|
|
304
|
+
</ul>
|
|
305
|
+
</SettingsSection>
|
|
306
|
+
{/if}
|
|
307
|
+
{/if}
|
|
308
|
+
</SettingsPage>
|
|
309
|
+
|
|
310
|
+
<style>
|
|
311
|
+
.history {
|
|
312
|
+
list-style: none;
|
|
313
|
+
margin: 0;
|
|
314
|
+
padding: 0;
|
|
315
|
+
}
|
|
316
|
+
.history button {
|
|
317
|
+
display: flex;
|
|
318
|
+
align-items: center;
|
|
319
|
+
gap: 10px;
|
|
320
|
+
width: 100%;
|
|
321
|
+
padding: 6px 0;
|
|
322
|
+
border: 0;
|
|
323
|
+
background: none;
|
|
324
|
+
color: inherit;
|
|
325
|
+
font: inherit;
|
|
326
|
+
text-align: start;
|
|
327
|
+
cursor: pointer;
|
|
328
|
+
}
|
|
329
|
+
.history button:hover .hwhen {
|
|
330
|
+
color: var(--kern-ink-700);
|
|
331
|
+
}
|
|
332
|
+
.hwhen {
|
|
333
|
+
flex: 1;
|
|
334
|
+
font-size: 12.5px;
|
|
335
|
+
color: var(--kern-ink-550);
|
|
336
|
+
}
|
|
337
|
+
.state {
|
|
338
|
+
display: grid;
|
|
339
|
+
place-items: center;
|
|
340
|
+
padding: 24px;
|
|
341
|
+
font-size: 13px;
|
|
342
|
+
}
|
|
343
|
+
.check {
|
|
344
|
+
display: flex;
|
|
345
|
+
align-items: center;
|
|
346
|
+
gap: 6px;
|
|
347
|
+
margin-top: 10px;
|
|
348
|
+
font-size: 13px;
|
|
349
|
+
}
|
|
350
|
+
.map {
|
|
351
|
+
width: 100%;
|
|
352
|
+
border-collapse: collapse;
|
|
353
|
+
font-size: 13px;
|
|
354
|
+
}
|
|
355
|
+
.map th,
|
|
356
|
+
.map td {
|
|
357
|
+
padding: 6px 8px 6px 0;
|
|
358
|
+
border-bottom: 1px solid var(--kern-border-hairline);
|
|
359
|
+
text-align: start;
|
|
360
|
+
vertical-align: middle;
|
|
361
|
+
}
|
|
362
|
+
.map thead th {
|
|
363
|
+
font-size: 11.5px;
|
|
364
|
+
text-transform: uppercase;
|
|
365
|
+
letter-spacing: 0.08em;
|
|
366
|
+
color: var(--kern-ink-400);
|
|
367
|
+
}
|
|
368
|
+
.example {
|
|
369
|
+
max-width: 180px;
|
|
370
|
+
overflow: hidden;
|
|
371
|
+
text-overflow: ellipsis;
|
|
372
|
+
white-space: nowrap;
|
|
373
|
+
color: var(--kern-ink-450, var(--kern-ink-400));
|
|
374
|
+
}
|
|
375
|
+
.warn {
|
|
376
|
+
margin: 10px 0 0;
|
|
377
|
+
font-size: 13px;
|
|
378
|
+
color: var(--kern-warning);
|
|
379
|
+
}
|
|
380
|
+
.jrow {
|
|
381
|
+
display: flex;
|
|
382
|
+
align-items: center;
|
|
383
|
+
gap: 10px;
|
|
384
|
+
margin-bottom: 8px;
|
|
385
|
+
}
|
|
386
|
+
.counts {
|
|
387
|
+
flex: 1;
|
|
388
|
+
font-size: 13px;
|
|
389
|
+
}
|
|
390
|
+
.errors {
|
|
391
|
+
list-style: none;
|
|
392
|
+
margin: 10px 0 0;
|
|
393
|
+
padding: 0;
|
|
394
|
+
display: flex;
|
|
395
|
+
flex-direction: column;
|
|
396
|
+
gap: 2px;
|
|
397
|
+
font-size: 12.5px;
|
|
398
|
+
color: var(--kern-danger);
|
|
399
|
+
}
|
|
400
|
+
.errors .row {
|
|
401
|
+
font-family: var(--kern-font-mono);
|
|
402
|
+
font-size: 11.5px;
|
|
403
|
+
margin-inline-end: 6px;
|
|
404
|
+
}
|
|
405
|
+
.note {
|
|
406
|
+
margin: 6px 0 0;
|
|
407
|
+
font-size: 12px;
|
|
408
|
+
color: var(--kern-ink-400);
|
|
409
|
+
}
|
|
410
|
+
</style>
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { DropdownMenu, Icon, IconButton, type MenuItem } from '@kernhq/ui'
|
|
3
|
+
import { untrack } from 'svelte'
|
|
4
|
+
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import type { FieldLayoutItem, ResolvedField, ResolvedLayout } from '../index.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where each field sits on a work item type: the main column, the sidebar, or hidden.
|
|
10
|
+
*
|
|
11
|
+
* Three drop zones over `svelte-dnd-action`, with both lessons the board taught: `$state.raw`,
|
|
12
|
+
* because a deep-reactive proxy hands the drag library a different object on every read and it
|
|
13
|
+
* reads that as an endless stream of changes; and a `dragging` guard, so a refetch cannot yank the
|
|
14
|
+
* lists out from under a drag in progress.
|
|
15
|
+
*
|
|
16
|
+
* Pinned fields render locked. An issue without a title, a status or a type is not an issue.
|
|
17
|
+
*/
|
|
18
|
+
interface Props {
|
|
19
|
+
layout: ResolvedLayout
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
onchange: (items: FieldLayoutItem[]) => void
|
|
22
|
+
}
|
|
23
|
+
let { layout, disabled = false, onchange }: Props = $props()
|
|
24
|
+
|
|
25
|
+
type Zone = 'main' | 'sidebar' | 'hidden'
|
|
26
|
+
/**
|
|
27
|
+
* `svelte-dnd-action` tracks items by an `id` property and nothing else. A `ResolvedField` is keyed
|
|
28
|
+
* by `fieldId`, so without this the zones look populated and refuse to move anything at all —
|
|
29
|
+
* silently, by mouse and by keyboard alike.
|
|
30
|
+
*/
|
|
31
|
+
type Draggable = ResolvedField & { id: string }
|
|
32
|
+
const draggable = (field: ResolvedField): Draggable => ({ ...field, id: field.fieldId })
|
|
33
|
+
const ZONES: Array<{ id: Zone; label: () => string; hint: () => string }> = [
|
|
34
|
+
{ id: 'main', label: () => t('layout_main'), hint: () => t('layout_main_hint') },
|
|
35
|
+
{ id: 'sidebar', label: () => t('layout_sidebar'), hint: () => t('layout_sidebar_hint') },
|
|
36
|
+
{ id: 'hidden', label: () => t('layout_hidden'), hint: () => t('layout_hidden_hint') },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
let columns = $state.raw<Record<Zone, Draggable[]>>({ main: [], sidebar: [], hidden: [] })
|
|
40
|
+
let dragging = $state(false)
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Seed the zones from the resolved layout, and *only* from it.
|
|
44
|
+
*
|
|
45
|
+
* `dragging` is read through `untrack` on purpose: reading it normally makes it a dependency, so
|
|
46
|
+
* clearing it at the end of a drop re-runs this effect, which re-seeds the zones from the server's
|
|
47
|
+
* layout and undoes the move on screen — while the change that was already emitted is still
|
|
48
|
+
* pending. The screen and the unsaved change would disagree.
|
|
49
|
+
*/
|
|
50
|
+
$effect(() => {
|
|
51
|
+
const next = layout
|
|
52
|
+
untrack(() => {
|
|
53
|
+
if (dragging) return
|
|
54
|
+
columns = {
|
|
55
|
+
main: next.main.map(draggable),
|
|
56
|
+
sidebar: next.sidebar.map(draggable),
|
|
57
|
+
hidden: next.hidden.map(draggable),
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const isShadow = (field: Draggable) =>
|
|
63
|
+
(field as unknown as Record<string, unknown>)[SHADOW_ITEM_MARKER_PROPERTY_NAME] === true
|
|
64
|
+
|
|
65
|
+
/** The whole arrangement as the contract stores it: one entry per field, in the order shown. */
|
|
66
|
+
function emit(next: Record<Zone, Draggable[]>) {
|
|
67
|
+
const items: FieldLayoutItem[] = []
|
|
68
|
+
for (const zone of ['main', 'sidebar', 'hidden'] as Zone[])
|
|
69
|
+
for (const [index, field] of next[zone].entries()) {
|
|
70
|
+
if (isShadow(field)) continue
|
|
71
|
+
items.push({
|
|
72
|
+
fieldId: field.fieldId,
|
|
73
|
+
section: zone,
|
|
74
|
+
order: index,
|
|
75
|
+
required: field.required && !field.pinned,
|
|
76
|
+
hidden: zone === 'hidden',
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
onchange(items)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Move a field without dragging.
|
|
84
|
+
*
|
|
85
|
+
* A drag is a fine gesture and a poor requirement: it is unreachable by keyboard and by anyone who
|
|
86
|
+
* cannot hold a pointer steady. Every arrangement possible by dragging is possible here too.
|
|
87
|
+
*/
|
|
88
|
+
function moveTo(field: Draggable, to: Zone) {
|
|
89
|
+
const next: Record<Zone, Draggable[]> = {
|
|
90
|
+
main: columns.main.filter((f) => f.id !== field.id),
|
|
91
|
+
sidebar: columns.sidebar.filter((f) => f.id !== field.id),
|
|
92
|
+
hidden: columns.hidden.filter((f) => f.id !== field.id),
|
|
93
|
+
}
|
|
94
|
+
// A pinned field is never hidden, whatever route asked for it.
|
|
95
|
+
const target = to === 'hidden' && field.pinned ? 'sidebar' : to
|
|
96
|
+
next[target] = [...next[target], field]
|
|
97
|
+
columns = next
|
|
98
|
+
emit(next)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const moveMenu = (field: Draggable, from: Zone): MenuItem[] =>
|
|
102
|
+
ZONES.filter((z) => z.id !== from && !(z.id === 'hidden' && field.pinned)).map((z) => ({
|
|
103
|
+
type: 'item' as const,
|
|
104
|
+
id: z.id,
|
|
105
|
+
label: t('layout_move_to', { zone: z.label() }),
|
|
106
|
+
onSelect: () => moveTo(field, z.id),
|
|
107
|
+
}))
|
|
108
|
+
|
|
109
|
+
function consider(zone: Zone, event: CustomEvent<{ items: Draggable[] }>) {
|
|
110
|
+
dragging = true
|
|
111
|
+
columns = { ...columns, [zone]: event.detail.items }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function finalize(zone: Zone, event: CustomEvent<{ items: Draggable[] }>) {
|
|
115
|
+
let next = { ...columns, [zone]: event.detail.items }
|
|
116
|
+
// A pinned field that somehow reached `hidden` goes back to the sidebar. Enforcing it here rather
|
|
117
|
+
// than refusing the drop covers every route in — drag, keyboard, a stored layout written by hand.
|
|
118
|
+
if (zone === 'hidden') {
|
|
119
|
+
const stowaways = next.hidden.filter((f) => f.pinned)
|
|
120
|
+
if (stowaways.length)
|
|
121
|
+
next = {
|
|
122
|
+
...next,
|
|
123
|
+
hidden: next.hidden.filter((f) => !f.pinned),
|
|
124
|
+
sidebar: [...next.sidebar, ...stowaways],
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
columns = next
|
|
128
|
+
dragging = false
|
|
129
|
+
emit(next)
|
|
130
|
+
}
|
|
131
|
+
</script>
|
|
132
|
+
|
|
133
|
+
<div class="zones">
|
|
134
|
+
{#each ZONES as zone (zone.id)}
|
|
135
|
+
<section class="zone">
|
|
136
|
+
<header>
|
|
137
|
+
<span class="kern-sublabel">{zone.label()}</span>
|
|
138
|
+
<span class="hint">{zone.hint()}</span>
|
|
139
|
+
</header>
|
|
140
|
+
<ul
|
|
141
|
+
class="drop"
|
|
142
|
+
class:empty={columns[zone.id].length === 0}
|
|
143
|
+
data-testid="zone-{zone.id}"
|
|
144
|
+
use:dndzone={{
|
|
145
|
+
items: columns[zone.id],
|
|
146
|
+
dragDisabled: disabled,
|
|
147
|
+
dropFromOthersDisabled: disabled,
|
|
148
|
+
flipDurationMs: 120,
|
|
149
|
+
dropTargetStyle: {},
|
|
150
|
+
}}
|
|
151
|
+
onconsider={(e) => consider(zone.id, e)}
|
|
152
|
+
onfinalize={(e) => finalize(zone.id, e)}
|
|
153
|
+
>
|
|
154
|
+
{#each columns[zone.id] as field (field.id)}
|
|
155
|
+
<li class:pinned={field.pinned} data-field-id={field.fieldId}>
|
|
156
|
+
<Icon name={field.pinned ? 'lock' : 'menu'} size={13} strokeWidth={1.8} />
|
|
157
|
+
<span class="label">{field.label}</span>
|
|
158
|
+
{#if field.kind === 'custom'}
|
|
159
|
+
<span class="key">cf.{field.field?.key}</span>
|
|
160
|
+
{/if}
|
|
161
|
+
{#if field.required}
|
|
162
|
+
<span class="req" title={t('field_required')}>*</span>
|
|
163
|
+
{/if}
|
|
164
|
+
{#if !disabled}
|
|
165
|
+
<DropdownMenu items={moveMenu(field, zone.id)} align="end">
|
|
166
|
+
{#snippet trigger(props)}
|
|
167
|
+
<IconButton
|
|
168
|
+
{...props}
|
|
169
|
+
icon="ellipsis"
|
|
170
|
+
size={22}
|
|
171
|
+
label={t('layout_move', { field: field.label })}
|
|
172
|
+
/>
|
|
173
|
+
{/snippet}
|
|
174
|
+
</DropdownMenu>
|
|
175
|
+
{/if}
|
|
176
|
+
</li>
|
|
177
|
+
{/each}
|
|
178
|
+
{#if columns[zone.id].length === 0}
|
|
179
|
+
<li class="placeholder">{t('layout_drop_here')}</li>
|
|
180
|
+
{/if}
|
|
181
|
+
</ul>
|
|
182
|
+
</section>
|
|
183
|
+
{/each}
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<style>
|
|
187
|
+
.zones {
|
|
188
|
+
display: grid;
|
|
189
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
190
|
+
gap: 12px;
|
|
191
|
+
}
|
|
192
|
+
@media (max-width: 720px) {
|
|
193
|
+
.zones {
|
|
194
|
+
grid-template-columns: 1fr;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
header {
|
|
198
|
+
display: flex;
|
|
199
|
+
flex-direction: column;
|
|
200
|
+
gap: 1px;
|
|
201
|
+
margin-bottom: 6px;
|
|
202
|
+
}
|
|
203
|
+
.hint {
|
|
204
|
+
font-size: 11.5px;
|
|
205
|
+
color: var(--kern-ink-400);
|
|
206
|
+
}
|
|
207
|
+
.drop {
|
|
208
|
+
list-style: none;
|
|
209
|
+
margin: 0;
|
|
210
|
+
padding: 6px;
|
|
211
|
+
/* an empty zone still has to be a target, so it keeps a height of its own */
|
|
212
|
+
min-height: 96px;
|
|
213
|
+
display: flex;
|
|
214
|
+
flex-direction: column;
|
|
215
|
+
gap: 4px;
|
|
216
|
+
border: 1px dashed var(--kern-border);
|
|
217
|
+
border-radius: var(--kern-r-sm);
|
|
218
|
+
background: var(--kern-shell);
|
|
219
|
+
}
|
|
220
|
+
.drop li {
|
|
221
|
+
display: flex;
|
|
222
|
+
align-items: center;
|
|
223
|
+
gap: 6px;
|
|
224
|
+
padding: 5px 8px;
|
|
225
|
+
border: 1px solid var(--kern-border);
|
|
226
|
+
border-radius: var(--kern-r-sm);
|
|
227
|
+
background: var(--kern-surface);
|
|
228
|
+
font-size: 13px;
|
|
229
|
+
cursor: grab;
|
|
230
|
+
}
|
|
231
|
+
.drop li.pinned {
|
|
232
|
+
cursor: default;
|
|
233
|
+
/*
|
|
234
|
+
* Muted by colour, not by `opacity`. A row at 0.75 fades its text against the page it sits on —
|
|
235
|
+
* the required marker inside it dropped to 3.2:1, which is a readability failure rather than a
|
|
236
|
+
* visual hint — and opacity fades everything by the same amount whatever its starting contrast.
|
|
237
|
+
*/
|
|
238
|
+
color: var(--kern-ink-450);
|
|
239
|
+
}
|
|
240
|
+
.drop li.placeholder {
|
|
241
|
+
justify-content: center;
|
|
242
|
+
border-style: dashed;
|
|
243
|
+
border-color: transparent;
|
|
244
|
+
background: none;
|
|
245
|
+
color: var(--kern-ink-400);
|
|
246
|
+
font-size: 12px;
|
|
247
|
+
cursor: default;
|
|
248
|
+
}
|
|
249
|
+
.label {
|
|
250
|
+
flex: 1;
|
|
251
|
+
overflow: hidden;
|
|
252
|
+
text-overflow: ellipsis;
|
|
253
|
+
white-space: nowrap;
|
|
254
|
+
}
|
|
255
|
+
.key {
|
|
256
|
+
font-family: var(--kern-font-mono);
|
|
257
|
+
font-size: 11px;
|
|
258
|
+
color: var(--kern-ink-400);
|
|
259
|
+
}
|
|
260
|
+
.req {
|
|
261
|
+
color: var(--kern-warning);
|
|
262
|
+
}
|
|
263
|
+
</style>
|