@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,287 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, Checkbox, EmptyState, Icon, Input, Select, Spinner, Textarea } from '@kernhq/ui'
|
|
3
|
+
import { createMutation, createQuery } from '@tanstack/svelte-query'
|
|
4
|
+
import { getTrackerApi } from '../api-instance.js'
|
|
5
|
+
import { t } from '../i18n.js'
|
|
6
|
+
import type { IntakeForm } from '../index.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The form a stranger fills in.
|
|
10
|
+
*
|
|
11
|
+
* Public: no session, no workspace, no navigation — whoever opens this is not a member and may
|
|
12
|
+
* never become one. So it shows one thing and asks for as little as it can, and every question
|
|
13
|
+
* comes from the project's own layout rather than from a list kept in step by hand.
|
|
14
|
+
*
|
|
15
|
+
* The honeypot is a field real people never see, so anything in it came from a script. It is not a
|
|
16
|
+
* CAPTCHA and does not pretend to be: the server rate-limits as well.
|
|
17
|
+
*/
|
|
18
|
+
interface Props {
|
|
19
|
+
token: string
|
|
20
|
+
}
|
|
21
|
+
let { token }: Props = $props()
|
|
22
|
+
|
|
23
|
+
const api = getTrackerApi()
|
|
24
|
+
|
|
25
|
+
let answers = $state<Record<string, unknown>>({})
|
|
26
|
+
let website = $state('')
|
|
27
|
+
|
|
28
|
+
const formQuery = createQuery(() => ({
|
|
29
|
+
queryKey: ['tracker', 'intake', token],
|
|
30
|
+
queryFn: () => api.intake.form({ token }),
|
|
31
|
+
retry: false,
|
|
32
|
+
}))
|
|
33
|
+
const form = $derived((formQuery.data ?? null) as IntakeForm | null)
|
|
34
|
+
|
|
35
|
+
const value = (key: string) => answers[key]
|
|
36
|
+
const set = (key: string, next: unknown) => {
|
|
37
|
+
answers = { ...answers, [key]: next }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A question is answered when it holds something; a checkbox counts either way. */
|
|
41
|
+
const answered = (key: string, type: string) => {
|
|
42
|
+
const v = answers[key]
|
|
43
|
+
if (type === 'checkbox') return true
|
|
44
|
+
return v !== undefined && v !== null && v !== '' && !(Array.isArray(v) && v.length === 0)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const missing = $derived((form?.fields ?? []).filter((f) => f.required && !answered(f.key, f.type)))
|
|
48
|
+
const canSubmit = $derived(Boolean(form) && missing.length === 0)
|
|
49
|
+
|
|
50
|
+
const submit = createMutation(() => ({
|
|
51
|
+
mutationFn: () => {
|
|
52
|
+
const custom: Record<string, unknown> = {}
|
|
53
|
+
for (const field of form?.fields ?? [])
|
|
54
|
+
if (field.key.startsWith('cf.') && answered(field.key, field.type))
|
|
55
|
+
custom[field.key] = answers[field.key]
|
|
56
|
+
return api.intake.submit({
|
|
57
|
+
token,
|
|
58
|
+
title: String(answers.title ?? ''),
|
|
59
|
+
description: answers.description ? String(answers.description) : undefined,
|
|
60
|
+
email: answers.email ? String(answers.email) : undefined,
|
|
61
|
+
name: answers.name ? String(answers.name) : undefined,
|
|
62
|
+
...(Object.keys(custom).length ? { fields: custom } : {}),
|
|
63
|
+
// empty for a person, filled by a script
|
|
64
|
+
website,
|
|
65
|
+
} as never)
|
|
66
|
+
},
|
|
67
|
+
}))
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<svelte:head>
|
|
71
|
+
<title>{form ? form.title : t('intake_title')}</title>
|
|
72
|
+
<!-- Nothing here should be indexed: it is a form for one project, not a page. -->
|
|
73
|
+
<meta name="robots" content="noindex" />
|
|
74
|
+
</svelte:head>
|
|
75
|
+
|
|
76
|
+
<main class="intake">
|
|
77
|
+
{#if formQuery.isPending}
|
|
78
|
+
<div class="state"><Spinner /></div>
|
|
79
|
+
{:else if formQuery.isError || !form}
|
|
80
|
+
<!-- A wrong or withdrawn link says so plainly; there is nothing to sign in to. -->
|
|
81
|
+
<EmptyState icon="triangle-alert" title={t('intake_unavailable')} description={t('intake_unavailable_hint')} />
|
|
82
|
+
{:else if submit.isSuccess}
|
|
83
|
+
<div class="done" data-testid="intake-done">
|
|
84
|
+
<Icon name="circle-check" size={28} strokeWidth={1.6} />
|
|
85
|
+
<h1>{t('intake_thanks')}</h1>
|
|
86
|
+
<p>{t('intake_reference', { key: submit.data.issueKey })}</p>
|
|
87
|
+
</div>
|
|
88
|
+
{:else}
|
|
89
|
+
<header>
|
|
90
|
+
<h1>{form.title}</h1>
|
|
91
|
+
{#if form.description}<p class="lede">{form.description}</p>{/if}
|
|
92
|
+
</header>
|
|
93
|
+
|
|
94
|
+
<form
|
|
95
|
+
onsubmit={(e) => {
|
|
96
|
+
e.preventDefault()
|
|
97
|
+
if (canSubmit) submit.mutate()
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{#each form.fields as field (field.key)}
|
|
101
|
+
<label class="field">
|
|
102
|
+
<span class="q">
|
|
103
|
+
{field.label}
|
|
104
|
+
{#if field.required}<span class="req" aria-hidden="true">*</span>{/if}
|
|
105
|
+
</span>
|
|
106
|
+
{#if field.description}<span class="hint">{field.description}</span>{/if}
|
|
107
|
+
|
|
108
|
+
{#if field.type === 'textarea'}
|
|
109
|
+
<Textarea
|
|
110
|
+
rows={4}
|
|
111
|
+
value={String(value(field.key) ?? '')}
|
|
112
|
+
required={field.required}
|
|
113
|
+
oninput={(e: Event) => set(field.key, (e.currentTarget as HTMLTextAreaElement).value)}
|
|
114
|
+
/>
|
|
115
|
+
{:else if field.type === 'select'}
|
|
116
|
+
<Select
|
|
117
|
+
value={String(value(field.key) ?? '')}
|
|
118
|
+
options={(field.options ?? []).map((o) => ({ value: o.value, label: o.label }))}
|
|
119
|
+
onValueChange={(v: string) => set(field.key, v)}
|
|
120
|
+
/>
|
|
121
|
+
{:else if field.type === 'multiselect'}
|
|
122
|
+
<span class="choices">
|
|
123
|
+
{#each field.options ?? [] as option (option.value)}
|
|
124
|
+
{@const chosen = Array.isArray(value(field.key))
|
|
125
|
+
? (value(field.key) as string[])
|
|
126
|
+
: []}
|
|
127
|
+
<label class="choice">
|
|
128
|
+
<Checkbox
|
|
129
|
+
checked={chosen.includes(option.value)}
|
|
130
|
+
ariaLabel={option.label}
|
|
131
|
+
onCheckedChange={(on: boolean) =>
|
|
132
|
+
set(
|
|
133
|
+
field.key,
|
|
134
|
+
on
|
|
135
|
+
? [...chosen, option.value]
|
|
136
|
+
: chosen.filter((v) => v !== option.value),
|
|
137
|
+
)}
|
|
138
|
+
/>
|
|
139
|
+
{option.label}
|
|
140
|
+
</label>
|
|
141
|
+
{/each}
|
|
142
|
+
</span>
|
|
143
|
+
{:else if field.type === 'checkbox'}
|
|
144
|
+
<span class="choice">
|
|
145
|
+
<Checkbox
|
|
146
|
+
checked={value(field.key) === true}
|
|
147
|
+
ariaLabel={field.label}
|
|
148
|
+
onCheckedChange={(on: boolean) => set(field.key, on)}
|
|
149
|
+
/>
|
|
150
|
+
</span>
|
|
151
|
+
{:else}
|
|
152
|
+
<Input
|
|
153
|
+
type={field.type === 'email'
|
|
154
|
+
? 'email'
|
|
155
|
+
: field.type === 'number'
|
|
156
|
+
? 'number'
|
|
157
|
+
: field.type === 'date'
|
|
158
|
+
? 'date'
|
|
159
|
+
: field.type === 'url'
|
|
160
|
+
? 'url'
|
|
161
|
+
: 'text'}
|
|
162
|
+
value={String(value(field.key) ?? '')}
|
|
163
|
+
required={field.required}
|
|
164
|
+
data-testid="intake-{field.key}"
|
|
165
|
+
oninput={(e: Event) => {
|
|
166
|
+
const raw = (e.currentTarget as HTMLInputElement).value
|
|
167
|
+
set(field.key, field.type === 'number' ? (raw === '' ? null : Number(raw)) : raw)
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
{/if}
|
|
171
|
+
</label>
|
|
172
|
+
{/each}
|
|
173
|
+
|
|
174
|
+
<!-- The honeypot. Hidden from people, off the tab order, and never autofilled. -->
|
|
175
|
+
<input
|
|
176
|
+
class="hp"
|
|
177
|
+
type="text"
|
|
178
|
+
tabindex="-1"
|
|
179
|
+
autocomplete="off"
|
|
180
|
+
aria-hidden="true"
|
|
181
|
+
bind:value={website}
|
|
182
|
+
/>
|
|
183
|
+
|
|
184
|
+
{#if submit.isError}
|
|
185
|
+
<p class="failed" role="alert">{t('intake_failed')}</p>
|
|
186
|
+
{/if}
|
|
187
|
+
|
|
188
|
+
<Button disabled={!canSubmit} loading={submit.isPending} onclick={() => canSubmit && submit.mutate()} data-testid="intake-submit">
|
|
189
|
+
{t('intake_send')}
|
|
190
|
+
</Button>
|
|
191
|
+
<p class="note">{t('intake_privacy')}</p>
|
|
192
|
+
</form>
|
|
193
|
+
{/if}
|
|
194
|
+
</main>
|
|
195
|
+
|
|
196
|
+
<style>
|
|
197
|
+
.intake {
|
|
198
|
+
max-width: 560px;
|
|
199
|
+
margin: 0 auto;
|
|
200
|
+
padding: 48px 20px 64px;
|
|
201
|
+
}
|
|
202
|
+
header h1 {
|
|
203
|
+
margin: 0;
|
|
204
|
+
font-size: 24px;
|
|
205
|
+
font-weight: 600;
|
|
206
|
+
letter-spacing: -0.01em;
|
|
207
|
+
}
|
|
208
|
+
.lede {
|
|
209
|
+
margin: 6px 0 0;
|
|
210
|
+
font-size: 14px;
|
|
211
|
+
color: var(--kern-ink-550);
|
|
212
|
+
}
|
|
213
|
+
form {
|
|
214
|
+
display: flex;
|
|
215
|
+
flex-direction: column;
|
|
216
|
+
gap: 14px;
|
|
217
|
+
margin-top: 24px;
|
|
218
|
+
}
|
|
219
|
+
.field {
|
|
220
|
+
display: flex;
|
|
221
|
+
flex-direction: column;
|
|
222
|
+
gap: 4px;
|
|
223
|
+
}
|
|
224
|
+
.q {
|
|
225
|
+
font-size: 13px;
|
|
226
|
+
font-weight: 500;
|
|
227
|
+
}
|
|
228
|
+
.req {
|
|
229
|
+
color: var(--kern-danger);
|
|
230
|
+
margin-inline-start: 2px;
|
|
231
|
+
}
|
|
232
|
+
.hint {
|
|
233
|
+
font-size: 12px;
|
|
234
|
+
color: var(--kern-ink-400);
|
|
235
|
+
}
|
|
236
|
+
.choices {
|
|
237
|
+
display: flex;
|
|
238
|
+
flex-wrap: wrap;
|
|
239
|
+
gap: 10px;
|
|
240
|
+
}
|
|
241
|
+
.choice {
|
|
242
|
+
display: inline-flex;
|
|
243
|
+
align-items: center;
|
|
244
|
+
gap: 6px;
|
|
245
|
+
font-size: 13px;
|
|
246
|
+
}
|
|
247
|
+
.hp {
|
|
248
|
+
position: absolute;
|
|
249
|
+
width: 1px;
|
|
250
|
+
height: 1px;
|
|
251
|
+
opacity: 0;
|
|
252
|
+
pointer-events: none;
|
|
253
|
+
}
|
|
254
|
+
.failed {
|
|
255
|
+
margin: 0;
|
|
256
|
+
font-size: 13px;
|
|
257
|
+
color: var(--kern-danger);
|
|
258
|
+
}
|
|
259
|
+
.note {
|
|
260
|
+
margin: 0;
|
|
261
|
+
font-size: 12px;
|
|
262
|
+
color: var(--kern-ink-400);
|
|
263
|
+
}
|
|
264
|
+
.state {
|
|
265
|
+
display: grid;
|
|
266
|
+
place-items: center;
|
|
267
|
+
padding: 64px;
|
|
268
|
+
}
|
|
269
|
+
.done {
|
|
270
|
+
display: grid;
|
|
271
|
+
justify-items: center;
|
|
272
|
+
gap: 6px;
|
|
273
|
+
padding: 64px 0;
|
|
274
|
+
text-align: center;
|
|
275
|
+
color: var(--kern-success, var(--kern-ink-900));
|
|
276
|
+
}
|
|
277
|
+
.done h1 {
|
|
278
|
+
margin: 0;
|
|
279
|
+
font-size: 20px;
|
|
280
|
+
font-weight: 600;
|
|
281
|
+
}
|
|
282
|
+
.done p {
|
|
283
|
+
margin: 0;
|
|
284
|
+
font-size: 14px;
|
|
285
|
+
color: var(--kern-ink-550);
|
|
286
|
+
}
|
|
287
|
+
</style>
|