@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,109 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button, DropdownMenu, IconButton, type MenuItem, navigation } from '@kernhq/ui'
|
|
3
|
+
import { t } from '../i18n.js'
|
|
4
|
+
import { canTracker } from '../permissions.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The tracker's control strip.
|
|
8
|
+
*
|
|
9
|
+
* A module that owns the sidebar owns the row above it: the shell's ⌘K box steps aside so this can
|
|
10
|
+
* sit where a "New issue" button belongs, rather than being stacked under a second search field.
|
|
11
|
+
* Split out of `TrackerSidebar` so the shell can place the two independently.
|
|
12
|
+
*/
|
|
13
|
+
interface Props {
|
|
14
|
+
workspaceSlug: string
|
|
15
|
+
}
|
|
16
|
+
let { workspaceSlug }: Props = $props()
|
|
17
|
+
|
|
18
|
+
const inTracker = $derived(navigation.pathname === `/${workspaceSlug}/tracker`)
|
|
19
|
+
const canCreate = $derived(canTracker('create'))
|
|
20
|
+
const canManageProjects = $derived(canTracker('projectManage'))
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Opening a dialog is a parameter on the page you are on, not a jump to a blank one: raising an
|
|
24
|
+
* issue from a filtered list keeps that list underneath.
|
|
25
|
+
*/
|
|
26
|
+
function ask(flag: string) {
|
|
27
|
+
/**
|
|
28
|
+
* Built from `navigation` rather than the router's URL object: a module cannot read `$app/state`,
|
|
29
|
+
* and it does not need an absolute URL to ask the shell to go somewhere.
|
|
30
|
+
*/
|
|
31
|
+
const path = inTracker ? navigation.pathname : `/${workspaceSlug}/tracker`
|
|
32
|
+
const params = new URLSearchParams(inTracker ? navigation.search : {})
|
|
33
|
+
params.set(flag, '1')
|
|
34
|
+
void navigation.go(`${path}?${params.toString()}`, { keepFocus: true, noScroll: true })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const createMenu = $derived<MenuItem[]>([
|
|
38
|
+
...(canCreate
|
|
39
|
+
? [
|
|
40
|
+
{
|
|
41
|
+
type: 'item' as const,
|
|
42
|
+
id: 'issue',
|
|
43
|
+
label: t('new_issue'),
|
|
44
|
+
icon: 'square-check-big',
|
|
45
|
+
shortcut: ['c'],
|
|
46
|
+
onSelect: () => ask('new'),
|
|
47
|
+
},
|
|
48
|
+
]
|
|
49
|
+
: []),
|
|
50
|
+
...(canManageProjects
|
|
51
|
+
? [
|
|
52
|
+
{
|
|
53
|
+
type: 'item' as const,
|
|
54
|
+
id: 'project',
|
|
55
|
+
label: t('project_new'),
|
|
56
|
+
icon: 'folder',
|
|
57
|
+
onSelect: () => ask('new_project'),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
type: 'item' as const,
|
|
61
|
+
id: 'import',
|
|
62
|
+
label: t('settings_import'),
|
|
63
|
+
icon: 'upload',
|
|
64
|
+
href: `/${workspaceSlug}/settings/tracker/import`,
|
|
65
|
+
},
|
|
66
|
+
]
|
|
67
|
+
: []),
|
|
68
|
+
])
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
{#if canCreate || canManageProjects}
|
|
72
|
+
<div class="controls">
|
|
73
|
+
<Button
|
|
74
|
+
icon="plus"
|
|
75
|
+
rounded="xl"
|
|
76
|
+
class="cta"
|
|
77
|
+
onclick={() => ask(canCreate ? 'new' : 'new_project')}
|
|
78
|
+
data-testid="sidebar-new-issue"
|
|
79
|
+
>
|
|
80
|
+
{canCreate ? t('new_issue') : t('project_new')}
|
|
81
|
+
</Button>
|
|
82
|
+
{#if createMenu.length > 1}
|
|
83
|
+
<DropdownMenu items={createMenu} align="end">
|
|
84
|
+
{#snippet trigger(props)}
|
|
85
|
+
<IconButton
|
|
86
|
+
{...props}
|
|
87
|
+
icon="chevron-down"
|
|
88
|
+
label={t('create_more')}
|
|
89
|
+
size={34}
|
|
90
|
+
radius={9}
|
|
91
|
+
variant="outline"
|
|
92
|
+
data-testid="sidebar-create-menu"
|
|
93
|
+
/>
|
|
94
|
+
{/snippet}
|
|
95
|
+
</DropdownMenu>
|
|
96
|
+
{/if}
|
|
97
|
+
</div>
|
|
98
|
+
{/if}
|
|
99
|
+
|
|
100
|
+
<style>
|
|
101
|
+
.controls {
|
|
102
|
+
display: flex;
|
|
103
|
+
gap: 6px;
|
|
104
|
+
align-items: center;
|
|
105
|
+
}
|
|
106
|
+
.controls :global(.cta) {
|
|
107
|
+
flex: 1;
|
|
108
|
+
}
|
|
109
|
+
</style>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { navigation, SidebarGroup, SidebarItem } from '@kernhq/ui'
|
|
3
|
+
import { t } from '../i18n.js'
|
|
4
|
+
import { isTrackerTarget, type TrackerTarget, trackerHref } from '../nav.js'
|
|
5
|
+
import SidebarProjects from './SidebarProjects.svelte'
|
|
6
|
+
import SidebarViews from './SidebarViews.svelte'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The tracker, in the application sidebar (DESIGN.md 2.3).
|
|
10
|
+
*
|
|
11
|
+
* The rail switches modules and the sidebar holds the one you are in, so this is the tracker's own
|
|
12
|
+
* navigation: what you look at, the queries somebody named, and the projects with the ways each of
|
|
13
|
+
* them is planned. Every row is a link to a query the issues screen reads out of the URL — no row
|
|
14
|
+
* puts the page into a state that cannot be linked to, shared or gone back from.
|
|
15
|
+
*
|
|
16
|
+
* The control strip above it is `TrackerControls`, contributed separately so the shell can place
|
|
17
|
+
* the two independently.
|
|
18
|
+
*/
|
|
19
|
+
interface Props {
|
|
20
|
+
workspaceSlug: string
|
|
21
|
+
}
|
|
22
|
+
let { workspaceSlug }: Props = $props()
|
|
23
|
+
|
|
24
|
+
const slug = $derived(workspaceSlug)
|
|
25
|
+
const params = $derived(new URLSearchParams(navigation.search))
|
|
26
|
+
const inTracker = $derived(navigation.pathname === `/${slug}/tracker`)
|
|
27
|
+
|
|
28
|
+
/** Saving a view or making a project is a parameter on the page you are on, not a jump elsewhere. */
|
|
29
|
+
function ask(flag: string) {
|
|
30
|
+
/**
|
|
31
|
+
* Built from `navigation` rather than the router's URL object: a module cannot read `$app/state`,
|
|
32
|
+
* and it does not need an absolute URL to ask the shell to go somewhere.
|
|
33
|
+
*/
|
|
34
|
+
const path = inTracker ? navigation.pathname : `/${workspaceSlug}/tracker`
|
|
35
|
+
const params = new URLSearchParams(inTracker ? navigation.search : {})
|
|
36
|
+
params.set(flag, '1')
|
|
37
|
+
void navigation.go(`${path}?${params.toString()}`, { keepFocus: true, noScroll: true })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** How the whole workspace's work is looked at, before any one project's. */
|
|
41
|
+
const rows: { id: string; label: () => string; icon: string; target: TrackerTarget }[] = [
|
|
42
|
+
{ id: 'mine', label: () => t('cmd_my_issues'), icon: 'circle-user', target: { preset: 'assigned' } },
|
|
43
|
+
{ id: 'all', label: () => t('all_issues'), icon: 'square-check-big', target: {} },
|
|
44
|
+
{
|
|
45
|
+
id: 'projects',
|
|
46
|
+
label: () => t('all_projects'),
|
|
47
|
+
icon: 'layout-grid',
|
|
48
|
+
target: { group: 'project' },
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<div class="tsb">
|
|
54
|
+
<SidebarGroup title={t('title')}>
|
|
55
|
+
{#each rows as row (row.id)}
|
|
56
|
+
<SidebarItem
|
|
57
|
+
label={row.label()}
|
|
58
|
+
icon={row.icon}
|
|
59
|
+
href={trackerHref(slug, row.target)}
|
|
60
|
+
active={inTracker && isTrackerTarget(params, row.target)}
|
|
61
|
+
data-testid="tracker-nav-{row.id}"
|
|
62
|
+
/>
|
|
63
|
+
{/each}
|
|
64
|
+
<SidebarItem
|
|
65
|
+
label={t('reports_title')}
|
|
66
|
+
icon="chart-line"
|
|
67
|
+
href="/{slug}/tracker/reports"
|
|
68
|
+
active={navigation.pathname === `/${slug}/tracker/reports`}
|
|
69
|
+
/>
|
|
70
|
+
</SidebarGroup>
|
|
71
|
+
|
|
72
|
+
<SidebarViews onsave={() => ask('save')} />
|
|
73
|
+
<SidebarProjects oncreate={() => ask('new_project')} />
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<style>
|
|
77
|
+
.tsb {
|
|
78
|
+
display: flex;
|
|
79
|
+
flex-direction: column;
|
|
80
|
+
flex: 1;
|
|
81
|
+
min-height: 0;
|
|
82
|
+
}
|
|
83
|
+
/* DESIGN.md 2.3 control strip: the primary action takes the row, the menu takes what is left. */
|
|
84
|
+
.controls {
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
gap: 6px;
|
|
88
|
+
padding: 12px 12px 4px;
|
|
89
|
+
flex: none;
|
|
90
|
+
}
|
|
91
|
+
.controls :global(.cta) {
|
|
92
|
+
flex: 1;
|
|
93
|
+
min-width: 0;
|
|
94
|
+
height: 34px;
|
|
95
|
+
}
|
|
96
|
+
</style>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte'
|
|
2
|
+
import type {
|
|
3
|
+
Component,
|
|
4
|
+
Cycle,
|
|
5
|
+
FieldDef,
|
|
6
|
+
Label,
|
|
7
|
+
Milestone,
|
|
8
|
+
Project,
|
|
9
|
+
StatusInfo,
|
|
10
|
+
WorkItemType,
|
|
11
|
+
} from './index.js'
|
|
12
|
+
|
|
13
|
+
export interface Person {
|
|
14
|
+
id: string
|
|
15
|
+
name: string
|
|
16
|
+
avatarUrl: string | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The reference data every tracker view needs to turn ids into something a person can read.
|
|
21
|
+
*
|
|
22
|
+
* Issues carry ids, not names: a row has a `projectId`, a `statusId` and `assigneeIds`. Rather than
|
|
23
|
+
* threading six lists through every component, the page loads them once and publishes them here, and
|
|
24
|
+
* the rows, cards and detail panel look up what they need. Kept in runes so a refetch (or a realtime
|
|
25
|
+
* change) redraws the views that read it.
|
|
26
|
+
*/
|
|
27
|
+
export class TrackerCatalogue {
|
|
28
|
+
projects = $state<Project[]>([])
|
|
29
|
+
statuses = $state<StatusInfo[]>([])
|
|
30
|
+
types = $state<WorkItemType[]>([])
|
|
31
|
+
labels = $state<Label[]>([])
|
|
32
|
+
cycles = $state<Cycle[]>([])
|
|
33
|
+
milestones = $state<Milestone[]>([])
|
|
34
|
+
components = $state<Component[]>([])
|
|
35
|
+
people = $state<Person[]>([])
|
|
36
|
+
/** The workspace's custom fields, so cards and group headings can read a value's label. */
|
|
37
|
+
fields = $state<FieldDef[]>([])
|
|
38
|
+
|
|
39
|
+
#projects = $derived(new Map(this.projects.map((p) => [p.id, p])))
|
|
40
|
+
#statuses = $derived(new Map(this.statuses.map((s) => [s.id, s])))
|
|
41
|
+
#types = $derived(new Map(this.types.map((t) => [t.id, t])))
|
|
42
|
+
#labels = $derived(new Map(this.labels.map((l) => [l.id, l])))
|
|
43
|
+
#cycles = $derived(new Map(this.cycles.map((c) => [c.id, c])))
|
|
44
|
+
#milestones = $derived(new Map(this.milestones.map((ms) => [ms.id, ms])))
|
|
45
|
+
#components = $derived(new Map(this.components.map((c) => [c.id, c])))
|
|
46
|
+
#people = $derived(new Map(this.people.map((p) => [p.id, p])))
|
|
47
|
+
#fields = $derived(new Map(this.fields.map((f) => [f.key, f])))
|
|
48
|
+
|
|
49
|
+
project = (id: string | null | undefined) => (id ? this.#projects.get(id) : undefined)
|
|
50
|
+
status = (id: string | null | undefined) => (id ? this.#statuses.get(id) : undefined)
|
|
51
|
+
type = (id: string | null | undefined) => (id ? this.#types.get(id) : undefined)
|
|
52
|
+
label = (id: string | null | undefined) => (id ? this.#labels.get(id) : undefined)
|
|
53
|
+
cycle = (id: string | null | undefined) => (id ? this.#cycles.get(id) : undefined)
|
|
54
|
+
milestone = (id: string | null | undefined) => (id ? this.#milestones.get(id) : undefined)
|
|
55
|
+
component = (id: string | null | undefined) => (id ? this.#components.get(id) : undefined)
|
|
56
|
+
person = (id: string | null | undefined) => (id ? this.#people.get(id) : undefined)
|
|
57
|
+
field = (key: string | null | undefined) => (key ? this.#fields.get(key) : undefined)
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What a value of a custom field is called.
|
|
61
|
+
*
|
|
62
|
+
* A `select` stores an option id, so a group heading that showed the raw value would read
|
|
63
|
+
* `opt_7f3a` instead of `Sev 1`.
|
|
64
|
+
*/
|
|
65
|
+
customValueLabel = (fieldKey: string, value: string): string => {
|
|
66
|
+
const field = this.#fields.get(fieldKey)
|
|
67
|
+
if (!field) return value
|
|
68
|
+
const option = field.options.find((o) => o.id === value)
|
|
69
|
+
if (option) return option.label
|
|
70
|
+
if (field.type === 'user' || field.type === 'multiuser') return this.person(value)?.name ?? value
|
|
71
|
+
return value
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What a sum of estimates is said in.
|
|
76
|
+
*
|
|
77
|
+
* Per-issue the unit comes off the issue, but a group heading adds several up: they can only be
|
|
78
|
+
* added at all when the projects in view agree, and where they do not, points is what a mixed sum
|
|
79
|
+
* already meant.
|
|
80
|
+
*/
|
|
81
|
+
estimateUnit = $derived.by<'points' | 'hours' | 'none'>(() => {
|
|
82
|
+
const units = new Set(this.projects.map((p) => p.settings.estimation))
|
|
83
|
+
const only = units.size === 1 ? [...units][0] : null
|
|
84
|
+
return only === 'hours' ? 'hours' : 'points'
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/** The active cycle drives the sprint progress bar in the header. */
|
|
88
|
+
activeCycle = $derived(this.cycles.find((c) => c.status === 'active') ?? null)
|
|
89
|
+
|
|
90
|
+
/** Workflow order of a status, used to sort list groups and board columns. */
|
|
91
|
+
statusOrder = (id: string) => this.status(id)?.order ?? 99
|
|
92
|
+
statusCategory = (id: string) => this.status(id)?.category ?? 'todo'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const KEY = Symbol('tracker.catalogue')
|
|
96
|
+
|
|
97
|
+
export function setTrackerCatalogue(catalogue: TrackerCatalogue): TrackerCatalogue {
|
|
98
|
+
return setContext(KEY, catalogue)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getTrackerCatalogue(): TrackerCatalogue {
|
|
102
|
+
const catalogue = getContext<TrackerCatalogue | undefined>(KEY)
|
|
103
|
+
if (!catalogue) throw new Error('Tracker components must be rendered inside the tracker page')
|
|
104
|
+
return catalogue
|
|
105
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { core } from '@kernhq/contracts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The slice of core's API tracker reaches for, named by shape.
|
|
5
|
+
*
|
|
6
|
+
* Tracker needs the workspace's members to fill an assignee picker and draw an avatar on a row.
|
|
7
|
+
* Typing the seam structurally keeps the dependency pointing one way: tracker does not import
|
|
8
|
+
* core's router type, and core does not know tracker exists.
|
|
9
|
+
*/
|
|
10
|
+
export interface CoreMember {
|
|
11
|
+
userId: string
|
|
12
|
+
user: {
|
|
13
|
+
id: string
|
|
14
|
+
name: string | null
|
|
15
|
+
email: string
|
|
16
|
+
username?: string | null
|
|
17
|
+
avatarUrl?: string | null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CoreApi {
|
|
22
|
+
workspaces: {
|
|
23
|
+
members: {
|
|
24
|
+
list(input: { workspaceId: string; limit?: number }): Promise<{ items: CoreMember[] }>
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
files: {
|
|
28
|
+
downloadUrl(input: {
|
|
29
|
+
id: string
|
|
30
|
+
disposition?: 'inline' | 'attachment'
|
|
31
|
+
thumbnail?: boolean
|
|
32
|
+
}): Promise<{ url: string }>
|
|
33
|
+
get(input: { id: string }): Promise<core.FileObject>
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { previewCsv, splitLine } from './csv.js'
|
|
3
|
+
|
|
4
|
+
describe('splitLine', () => {
|
|
5
|
+
it('splits on the delimiter', () => {
|
|
6
|
+
expect(splitLine('a,b,c')).toEqual(['a', 'b', 'c'])
|
|
7
|
+
expect(splitLine('a;b', ';')).toEqual(['a', 'b'])
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('keeps a quoted field whole, delimiter and all', () => {
|
|
11
|
+
// The single most common way a naive import mangles every row after the first title.
|
|
12
|
+
expect(splitLine('1,"Crash, then burn",high')).toEqual(['1', 'Crash, then burn', 'high'])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('reads a doubled quote as one quote', () => {
|
|
16
|
+
expect(splitLine('"He said ""no""",x')).toEqual(['He said "no"', 'x'])
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('keeps empty fields, so the columns still line up', () => {
|
|
20
|
+
expect(splitLine('a,,c')).toEqual(['a', '', 'c'])
|
|
21
|
+
expect(splitLine(',')).toEqual(['', ''])
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('previewCsv', () => {
|
|
26
|
+
const file = 'Title,Priority\nFix the thing,high\nAnother,low\n'
|
|
27
|
+
|
|
28
|
+
it('takes the column names from the header', () => {
|
|
29
|
+
expect(previewCsv(file).columns).toEqual(['Title', 'Priority'])
|
|
30
|
+
expect(previewCsv(file).rows[0]).toEqual(['Fix the thing', 'high'])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('numbers the columns from zero without a header, matching what the server indexes by', () => {
|
|
34
|
+
// Showing 1,2,3 where the server counts 0,1,2 maps every field one place out.
|
|
35
|
+
const preview = previewCsv(file, { hasHeader: false })
|
|
36
|
+
expect(preview.columns).toEqual(['0', '1'])
|
|
37
|
+
expect(preview.rows[0]).toEqual(['Title', 'Priority'])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('ignores blank lines and stops after a few rows', () => {
|
|
41
|
+
const many = ['H', ...Array.from({ length: 20 }, (_, i) => `row ${i}`), '', ''].join('\n')
|
|
42
|
+
const preview = previewCsv(many)
|
|
43
|
+
expect(preview.rows).toHaveLength(3)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('has nothing to show for an empty file', () => {
|
|
47
|
+
expect(previewCsv('')).toEqual({ columns: [], rows: [] })
|
|
48
|
+
expect(previewCsv('\n\n')).toEqual({ columns: [], rows: [] })
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Just enough CSV to show somebody what they are about to import.
|
|
3
|
+
*
|
|
4
|
+
* The server parses the file properly; this reads the first few lines so the mapping screen can
|
|
5
|
+
* offer the real column names and a row of real values. Guessing at column names, or asking
|
|
6
|
+
* somebody to map "column 3", turns a five-minute import into a spreadsheet-counting exercise.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Split one CSV line, honouring quotes — a title with a comma in it is one field, not two. */
|
|
10
|
+
export function splitLine(line: string, delimiter = ','): string[] {
|
|
11
|
+
const fields: string[] = []
|
|
12
|
+
let current = ''
|
|
13
|
+
let quoted = false
|
|
14
|
+
for (let i = 0; i < line.length; i++) {
|
|
15
|
+
const char = line[i]!
|
|
16
|
+
if (quoted) {
|
|
17
|
+
// "" inside a quoted field is one literal quote.
|
|
18
|
+
if (char === '"' && line[i + 1] === '"') {
|
|
19
|
+
current += '"'
|
|
20
|
+
i++
|
|
21
|
+
} else if (char === '"') quoted = false
|
|
22
|
+
else current += char
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
if (char === '"') quoted = true
|
|
26
|
+
else if (char === delimiter) {
|
|
27
|
+
fields.push(current.trim())
|
|
28
|
+
current = ''
|
|
29
|
+
} else current += char
|
|
30
|
+
}
|
|
31
|
+
fields.push(current.trim())
|
|
32
|
+
return fields
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CsvPreview {
|
|
36
|
+
columns: string[]
|
|
37
|
+
/** the first rows, for showing what a column actually holds */
|
|
38
|
+
rows: string[][]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Column names and a few rows from the start of a file.
|
|
43
|
+
*
|
|
44
|
+
* Without a header row the columns are numbered, because that is what the server will index them
|
|
45
|
+
* by — showing "1, 2, 3" where the server counts "0, 1, 2" would map every field one place out.
|
|
46
|
+
*/
|
|
47
|
+
export function previewCsv(text: string, opts: { delimiter?: string; hasHeader?: boolean } = {}): CsvPreview {
|
|
48
|
+
const delimiter = opts.delimiter || ','
|
|
49
|
+
const hasHeader = opts.hasHeader ?? true
|
|
50
|
+
const lines = text
|
|
51
|
+
.split(/\r?\n/)
|
|
52
|
+
.filter((line) => line.trim() !== '')
|
|
53
|
+
.slice(0, 6)
|
|
54
|
+
if (!lines.length) return { columns: [], rows: [] }
|
|
55
|
+
|
|
56
|
+
const first = splitLine(lines[0]!, delimiter)
|
|
57
|
+
const columns = hasHeader ? first : first.map((_, i) => String(i))
|
|
58
|
+
const rows = (hasHeader ? lines.slice(1) : lines).map((line) => splitLine(line, delimiter))
|
|
59
|
+
return { columns, rows: rows.slice(0, 3) }
|
|
60
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { parseKql } from './kql.js'
|
|
2
|
+
import type { Priority } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The visual filter, and how it becomes KQL.
|
|
6
|
+
*
|
|
7
|
+
* The toolbar has two ways to narrow the list, and they must not fight: the Filter menu builds a
|
|
8
|
+
* structured filter, the query box takes anything KQL can express. They are combined with `and`, so
|
|
9
|
+
* picking two labels and typing `due <= +7d` means what it looks like it means, and clearing the
|
|
10
|
+
* chip never touches what you typed.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately free of interface strings, so the composition rules can be unit-tested on their own;
|
|
13
|
+
* the names these appear under live in `./labels`.
|
|
14
|
+
*/
|
|
15
|
+
export interface TrackerFilters {
|
|
16
|
+
projectIds: string[]
|
|
17
|
+
statusIds: string[]
|
|
18
|
+
priorities: Priority[]
|
|
19
|
+
assigneeIds: string[]
|
|
20
|
+
labelIds: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const emptyFilters = (): TrackerFilters => ({
|
|
24
|
+
projectIds: [],
|
|
25
|
+
statusIds: [],
|
|
26
|
+
priorities: [],
|
|
27
|
+
assigneeIds: [],
|
|
28
|
+
labelIds: [],
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export const filterCount = (f: TrackerFilters): number =>
|
|
32
|
+
f.projectIds.length + f.statusIds.length + f.priorities.length + f.assigneeIds.length + f.labelIds.length
|
|
33
|
+
|
|
34
|
+
/** Ids are quoted: a uuid starts with a digit, which KQL would otherwise read as a number. */
|
|
35
|
+
const clause = (field: string, values: string[], quote = true): string | null => {
|
|
36
|
+
if (values.length === 0) return null
|
|
37
|
+
const rendered = values.map((v) => (quote ? JSON.stringify(v) : v))
|
|
38
|
+
return values.length === 1 ? `${field} = ${rendered[0]}` : `${field} in (${rendered.join(', ')})`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function filtersToKql(f: TrackerFilters): string {
|
|
42
|
+
return [
|
|
43
|
+
clause('project', f.projectIds),
|
|
44
|
+
clause('status', f.statusIds),
|
|
45
|
+
clause('priority', f.priorities, false),
|
|
46
|
+
clause('assignee', f.assigneeIds),
|
|
47
|
+
clause('label', f.labelIds),
|
|
48
|
+
]
|
|
49
|
+
.filter((c): c is string => c !== null)
|
|
50
|
+
.join(' and ')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The saved queries behind the preset tabs (DESIGN.md 2.5). */
|
|
54
|
+
export type Preset = 'assigned' | 'active' | 'backlog' | 'created' | 'subscribed' | 'all'
|
|
55
|
+
|
|
56
|
+
export const PRESETS: Preset[] = ['assigned', 'active', 'backlog', 'created', 'subscribed', 'all']
|
|
57
|
+
|
|
58
|
+
export function presetKql(preset: Preset): string {
|
|
59
|
+
switch (preset) {
|
|
60
|
+
case 'assigned':
|
|
61
|
+
return 'assignee = currentUser()'
|
|
62
|
+
case 'active':
|
|
63
|
+
return 'statusCategory in (todo, in_progress)'
|
|
64
|
+
case 'backlog':
|
|
65
|
+
return 'statusCategory in (backlog, triage)'
|
|
66
|
+
case 'created':
|
|
67
|
+
return 'reporter = currentUser()'
|
|
68
|
+
case 'subscribed':
|
|
69
|
+
return 'watcher = currentUser()'
|
|
70
|
+
default:
|
|
71
|
+
return ''
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A part has to be bracketed before it is joined with `and`, or its `or` swallows the parts around
|
|
77
|
+
* it: `assignee = currentUser() and priority = urgent or priority = high` means "mine and urgent, or
|
|
78
|
+
* anyone's high", which quietly shows other people's issues. Asking the parser is the only reliable
|
|
79
|
+
* test — `OR` is a keyword whatever its case, and the word may equally appear inside a quoted value
|
|
80
|
+
* ("editor or reviewer"), where it means nothing. A part we cannot parse is bracketed anyway: the
|
|
81
|
+
* server rejects it either way, and this keeps the failure the user's typo rather than ours.
|
|
82
|
+
*/
|
|
83
|
+
const needsBrackets = (part: string): boolean => {
|
|
84
|
+
const parsed = parseKql(part)
|
|
85
|
+
if (!parsed.ok || !parsed.ast) return true
|
|
86
|
+
return parsed.ast.where?.kind === 'or'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Everything the list is narrowed by, as one query the server can answer. */
|
|
90
|
+
export function composeKql(preset: Preset, filters: TrackerFilters, manual: string): string {
|
|
91
|
+
const parts = [presetKql(preset), filtersToKql(filters), manual.trim()].filter((part) => part.length > 0)
|
|
92
|
+
if (parts.length < 2) return parts[0] ?? ''
|
|
93
|
+
return parts.map((part) => (needsBrackets(part) ? `(${part})` : part)).join(' and ')
|
|
94
|
+
}
|