@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,2729 @@
|
|
|
1
|
+
import type { UserId, WorkspaceId } from '@kernhq/contracts'
|
|
2
|
+
import { type KqlExpr, type KqlValue, parseKql } from './kql.js'
|
|
3
|
+
import { rankBetween, rankSequence } from './rank.js'
|
|
4
|
+
import type {
|
|
5
|
+
Attachment,
|
|
6
|
+
Comment,
|
|
7
|
+
Component,
|
|
8
|
+
Cycle,
|
|
9
|
+
FieldDef,
|
|
10
|
+
GroupBy,
|
|
11
|
+
ImportJob,
|
|
12
|
+
Issue,
|
|
13
|
+
IssueApproval,
|
|
14
|
+
IssueHistoryEntry,
|
|
15
|
+
IssueTemplate,
|
|
16
|
+
Label,
|
|
17
|
+
Link,
|
|
18
|
+
Milestone,
|
|
19
|
+
Priority,
|
|
20
|
+
Project,
|
|
21
|
+
RecurrenceRule,
|
|
22
|
+
RecurringIssue,
|
|
23
|
+
RelationType,
|
|
24
|
+
RelationView,
|
|
25
|
+
ResolvedField,
|
|
26
|
+
ResolvedLayout,
|
|
27
|
+
StatusInfo,
|
|
28
|
+
Timer,
|
|
29
|
+
Version,
|
|
30
|
+
View,
|
|
31
|
+
Workflow,
|
|
32
|
+
WorkItemType,
|
|
33
|
+
Worklog,
|
|
34
|
+
} from './types.js'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* In-memory tracker backend.
|
|
38
|
+
*
|
|
39
|
+
* Enabled with `PUBLIC_API_MOCK=1` (`pnpm dev:mock`). It exists so the issues experience can be
|
|
40
|
+
* built, demoed and tested without Postgres and the services, and so the end-to-end tests have a
|
|
41
|
+
* deterministic backend. It answers the same procedures with the same shapes as the contract,
|
|
42
|
+
* including KQL: queries are parsed with the same reader the query box uses and evaluated against
|
|
43
|
+
* the seed data, so filtering behaves the way it will against the real server.
|
|
44
|
+
*
|
|
45
|
+
* Anything not implemented throws loudly rather than quietly returning nothing.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const NOW = Date.now()
|
|
49
|
+
const DAY = 864e5
|
|
50
|
+
const iso = (msAgo = 0) => new Date(NOW - msAgo).toISOString()
|
|
51
|
+
const day = (offsetDays: number) => new Date(NOW + offsetDays * DAY).toISOString().slice(0, 10)
|
|
52
|
+
/**
|
|
53
|
+
* Same id scheme as the core mock, so people and workspaces line up across the two.
|
|
54
|
+
*
|
|
55
|
+
* The contract brands user and workspace ids so they cannot be mixed up by accident; at runtime they
|
|
56
|
+
* are ordinary uuids, and this is the one place the mock mints them.
|
|
57
|
+
*/
|
|
58
|
+
const uid = (n: number) =>
|
|
59
|
+
`01920000-0000-7000-8000-${String(n).padStart(12, '0')}` as string & UserId & WorkspaceId
|
|
60
|
+
const clone = <T>(v: T): T => structuredClone(v)
|
|
61
|
+
|
|
62
|
+
type Uid = ReturnType<typeof uid>
|
|
63
|
+
|
|
64
|
+
const WORKSPACE = uid(10)
|
|
65
|
+
const PEOPLE = [uid(1), uid(2), uid(3), uid(4)] as const
|
|
66
|
+
/** Pick a person by index, wrapping — the seed data spreads reporters and actors around. */
|
|
67
|
+
const who = (i: number): Uid => PEOPLE[i % PEOPLE.length] as Uid
|
|
68
|
+
const PERSON_NAMES: Record<string, string> = {
|
|
69
|
+
[uid(1)]: 'Maya Rivera',
|
|
70
|
+
[uid(2)]: 'Dan Brekke',
|
|
71
|
+
[uid(3)]: 'Tomás Lindqvist',
|
|
72
|
+
[uid(4)]: 'Inés Cabrera',
|
|
73
|
+
}
|
|
74
|
+
/** The signed-in user in mock mode, so `assignee = currentUser()` resolves. */
|
|
75
|
+
const ME = uid(1)
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------------------------
|
|
78
|
+
// workflow
|
|
79
|
+
// ---------------------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const WORKFLOW_ID = uid(710)
|
|
82
|
+
|
|
83
|
+
const statuses: StatusInfo[] = [
|
|
84
|
+
{ id: 'triage', name: 'Triage', category: 'triage', color: null, order: 0, workflowId: WORKFLOW_ID },
|
|
85
|
+
{ id: 'backlog', name: 'Backlog', category: 'backlog', color: null, order: 1, workflowId: WORKFLOW_ID },
|
|
86
|
+
{ id: 'todo', name: 'Todo', category: 'todo', color: null, order: 2, workflowId: WORKFLOW_ID },
|
|
87
|
+
{
|
|
88
|
+
id: 'in_progress',
|
|
89
|
+
name: 'In progress',
|
|
90
|
+
category: 'in_progress',
|
|
91
|
+
color: null,
|
|
92
|
+
order: 3,
|
|
93
|
+
workflowId: WORKFLOW_ID,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'in_review',
|
|
97
|
+
name: 'In review',
|
|
98
|
+
category: 'in_progress',
|
|
99
|
+
color: null,
|
|
100
|
+
order: 4,
|
|
101
|
+
workflowId: WORKFLOW_ID,
|
|
102
|
+
},
|
|
103
|
+
{ id: 'done', name: 'Done', category: 'done', color: null, order: 5, workflowId: WORKFLOW_ID },
|
|
104
|
+
{
|
|
105
|
+
id: 'cancelled',
|
|
106
|
+
name: 'Cancelled',
|
|
107
|
+
category: 'cancelled',
|
|
108
|
+
color: null,
|
|
109
|
+
order: 6,
|
|
110
|
+
workflowId: WORKFLOW_ID,
|
|
111
|
+
},
|
|
112
|
+
]
|
|
113
|
+
const statusById = new Map(statuses.map((s) => [s.id, s]))
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------------------------
|
|
116
|
+
// projects, types, labels, planning
|
|
117
|
+
// ---------------------------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
const projectSettings: Project['settings'] = {
|
|
120
|
+
estimation: 'points',
|
|
121
|
+
estimateScale: [0, 1, 2, 3, 5, 8, 13, 21],
|
|
122
|
+
cycles: {
|
|
123
|
+
enabled: true,
|
|
124
|
+
lengthWeeks: 2,
|
|
125
|
+
cooldownDays: 0,
|
|
126
|
+
autoRoll: true,
|
|
127
|
+
startDay: 1,
|
|
128
|
+
autoCreateUpcoming: 1,
|
|
129
|
+
},
|
|
130
|
+
triage: { enabled: true },
|
|
131
|
+
sla: { enabled: false, goals: {}, pauseInCategories: ['backlog'] },
|
|
132
|
+
requireResolution: false,
|
|
133
|
+
autoCreateIssueChannel: false,
|
|
134
|
+
timezone: 'UTC',
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const project = (n: number, key: string, name: string, color: string, leadId: Uid): Project => ({
|
|
138
|
+
id: uid(n),
|
|
139
|
+
workspaceId: WORKSPACE,
|
|
140
|
+
key,
|
|
141
|
+
name,
|
|
142
|
+
description: null,
|
|
143
|
+
icon: null,
|
|
144
|
+
color,
|
|
145
|
+
leadId,
|
|
146
|
+
visibility: 'workspace',
|
|
147
|
+
defaultAssignee: 'unassigned',
|
|
148
|
+
workflowSchemeId: null,
|
|
149
|
+
typeSchemeId: null,
|
|
150
|
+
settings: projectSettings,
|
|
151
|
+
intakeToken: null,
|
|
152
|
+
issueCounter: 0,
|
|
153
|
+
cycleCounter: 0,
|
|
154
|
+
memberCount: 4,
|
|
155
|
+
openIssueCount: 0,
|
|
156
|
+
archivedAt: null,
|
|
157
|
+
createdBy: uid(1),
|
|
158
|
+
createdAt: iso(120 * DAY),
|
|
159
|
+
updatedAt: iso(2 * DAY),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const projects: Project[] = [
|
|
163
|
+
project(700, 'KRN', 'Kern Platform', '#7E6A93', uid(2)),
|
|
164
|
+
project(701, 'RTM', 'Realtime', '#6E8B62', uid(3)),
|
|
165
|
+
project(702, 'DOC', 'Docs', '#B49A5F', uid(4)),
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
const workItemType = (n: number, key: string, name: string, icon: string, level: number): WorkItemType => ({
|
|
169
|
+
id: uid(n),
|
|
170
|
+
workspaceId: WORKSPACE,
|
|
171
|
+
projectId: null,
|
|
172
|
+
key,
|
|
173
|
+
name,
|
|
174
|
+
description: null,
|
|
175
|
+
icon,
|
|
176
|
+
color: null,
|
|
177
|
+
level,
|
|
178
|
+
isDefault: key === 'task',
|
|
179
|
+
workflowId: WORKFLOW_ID,
|
|
180
|
+
fieldLayout: [],
|
|
181
|
+
templateBody: null,
|
|
182
|
+
order: n - 720,
|
|
183
|
+
archivedAt: null,
|
|
184
|
+
createdAt: iso(120 * DAY),
|
|
185
|
+
updatedAt: iso(120 * DAY),
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
/** Mutable, like every other setting here: the screen changes it and the demo remembers. */
|
|
189
|
+
const hierarchyRules = { allowSkipLevels: true, allowSameLevel: false, maxSubItemDepth: 1 }
|
|
190
|
+
|
|
191
|
+
const types: WorkItemType[] = [
|
|
192
|
+
workItemType(720, 'task', 'Task', 'square-check-big', 0),
|
|
193
|
+
// Bug carries a real layout, so the demo shows a customised type beside default ones: Severity
|
|
194
|
+
// is promoted above the built-in properties and Cycle is hidden.
|
|
195
|
+
{
|
|
196
|
+
...workItemType(721, 'bug', 'Bug', 'circle-alert', 0),
|
|
197
|
+
fieldLayout: [
|
|
198
|
+
{ fieldId: 'cf.severity', section: 'sidebar', order: 1, required: true, hidden: false },
|
|
199
|
+
{ fieldId: 'cycle', section: 'hidden', order: 20, required: false, hidden: true },
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
workItemType(722, 'story', 'Story', 'bookmark', 0),
|
|
203
|
+
workItemType(723, 'epic', 'Epic', 'zap', 1),
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
const label = (n: number, name: string, color: string, group: string | null = null): Label => ({
|
|
207
|
+
id: uid(n),
|
|
208
|
+
workspaceId: WORKSPACE,
|
|
209
|
+
projectId: null,
|
|
210
|
+
name,
|
|
211
|
+
color,
|
|
212
|
+
description: null,
|
|
213
|
+
groupName: group,
|
|
214
|
+
issueCount: 0,
|
|
215
|
+
archivedAt: null,
|
|
216
|
+
createdAt: iso(100 * DAY),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const labels: Label[] = [
|
|
220
|
+
label(740, 'performance', '#B4661C'),
|
|
221
|
+
label(741, 'security', '#A63D26'),
|
|
222
|
+
label(742, 'design', '#B7714E'),
|
|
223
|
+
label(743, 'infra', '#5F7383'),
|
|
224
|
+
label(744, 'good first issue', '#4F7A55'),
|
|
225
|
+
label(745, 'regression', '#7A55B5'),
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
const cycles: Cycle[] = [
|
|
229
|
+
{
|
|
230
|
+
id: uid(760),
|
|
231
|
+
workspaceId: WORKSPACE,
|
|
232
|
+
projectId: uid(700),
|
|
233
|
+
number: 23,
|
|
234
|
+
name: 'Sprint 23',
|
|
235
|
+
goal: 'Ship the permissions rewrite',
|
|
236
|
+
startAt: iso(28 * DAY),
|
|
237
|
+
endAt: iso(14 * DAY),
|
|
238
|
+
status: 'completed',
|
|
239
|
+
startedAt: iso(28 * DAY),
|
|
240
|
+
completedAt: iso(14 * DAY),
|
|
241
|
+
carryOverCount: 3,
|
|
242
|
+
stats: { total: 18, done: 15, estimateTotal: 41, estimateDone: 34 },
|
|
243
|
+
createdAt: iso(40 * DAY),
|
|
244
|
+
updatedAt: iso(14 * DAY),
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: uid(761),
|
|
248
|
+
workspaceId: WORKSPACE,
|
|
249
|
+
projectId: uid(700),
|
|
250
|
+
number: 24,
|
|
251
|
+
name: 'Sprint 24',
|
|
252
|
+
goal: 'Realtime presence and the new issue list',
|
|
253
|
+
startAt: iso(10 * DAY),
|
|
254
|
+
endAt: iso(-4 * DAY),
|
|
255
|
+
status: 'active',
|
|
256
|
+
startedAt: iso(10 * DAY),
|
|
257
|
+
completedAt: null,
|
|
258
|
+
carryOverCount: 3,
|
|
259
|
+
stats: { total: 21, done: 9, estimateTotal: 52, estimateDone: 21 },
|
|
260
|
+
createdAt: iso(20 * DAY),
|
|
261
|
+
updatedAt: iso(2 * 3600e3),
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
id: uid(762),
|
|
265
|
+
workspaceId: WORKSPACE,
|
|
266
|
+
projectId: uid(700),
|
|
267
|
+
number: 25,
|
|
268
|
+
name: 'Sprint 25',
|
|
269
|
+
goal: null,
|
|
270
|
+
startAt: iso(-4 * DAY),
|
|
271
|
+
endAt: iso(-18 * DAY),
|
|
272
|
+
status: 'upcoming',
|
|
273
|
+
startedAt: null,
|
|
274
|
+
completedAt: null,
|
|
275
|
+
carryOverCount: 0,
|
|
276
|
+
stats: { total: 4, done: 0, estimateTotal: 11, estimateDone: 0 },
|
|
277
|
+
createdAt: iso(6 * DAY),
|
|
278
|
+
updatedAt: iso(6 * DAY),
|
|
279
|
+
},
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
const component = (n: number, project: number, name: string): Component => ({
|
|
283
|
+
id: uid(n),
|
|
284
|
+
workspaceId: WORKSPACE,
|
|
285
|
+
projectId: uid(project),
|
|
286
|
+
name,
|
|
287
|
+
description: null,
|
|
288
|
+
leadId: null,
|
|
289
|
+
defaultAssignee: 'none',
|
|
290
|
+
issueCount: 0,
|
|
291
|
+
createdAt: iso(90 * DAY),
|
|
292
|
+
updatedAt: iso(90 * DAY),
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The parts each project is built from.
|
|
297
|
+
*
|
|
298
|
+
* Seeded rather than empty because the sidebar offers a project's components as a way to look at
|
|
299
|
+
* its work: with none, the demo's "Components" view is one heap called "No component".
|
|
300
|
+
*/
|
|
301
|
+
const components: Component[] = [
|
|
302
|
+
component(2200, 700, 'Sync engine'),
|
|
303
|
+
component(2201, 700, 'Kernel'),
|
|
304
|
+
component(2202, 701, 'Presence'),
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
const milestones: Milestone[] = [
|
|
308
|
+
{
|
|
309
|
+
id: uid(770),
|
|
310
|
+
workspaceId: WORKSPACE,
|
|
311
|
+
projectId: uid(700),
|
|
312
|
+
name: 'Beta launch',
|
|
313
|
+
description: 'Everything a self-hosted pilot needs',
|
|
314
|
+
targetDate: day(24),
|
|
315
|
+
status: 'open',
|
|
316
|
+
stats: { total: 14, done: 6 },
|
|
317
|
+
completedAt: null,
|
|
318
|
+
createdAt: iso(60 * DAY),
|
|
319
|
+
updatedAt: iso(3 * DAY),
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: uid(771),
|
|
323
|
+
workspaceId: WORKSPACE,
|
|
324
|
+
projectId: uid(701),
|
|
325
|
+
name: 'Presence v1',
|
|
326
|
+
description: null,
|
|
327
|
+
targetDate: day(10),
|
|
328
|
+
status: 'open',
|
|
329
|
+
stats: { total: 7, done: 4 },
|
|
330
|
+
completedAt: null,
|
|
331
|
+
createdAt: iso(45 * DAY),
|
|
332
|
+
updatedAt: iso(DAY),
|
|
333
|
+
},
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
const fields: FieldDef[] = [
|
|
337
|
+
{
|
|
338
|
+
id: uid(780),
|
|
339
|
+
workspaceId: WORKSPACE,
|
|
340
|
+
projectId: null,
|
|
341
|
+
key: 'severity',
|
|
342
|
+
name: 'Severity',
|
|
343
|
+
description: 'How badly this hurts in production',
|
|
344
|
+
type: 'select',
|
|
345
|
+
options: [
|
|
346
|
+
{ id: uid(781), label: 'S1', color: '#A63D26', order: 0, archived: false },
|
|
347
|
+
{ id: uid(782), label: 'S2', color: '#B4661C', order: 1, archived: false },
|
|
348
|
+
{ id: uid(783), label: 'S3', color: '#B49A5F', order: 2, archived: false },
|
|
349
|
+
{ id: uid(784), label: 'S4', color: '#8E8779', order: 3, archived: false },
|
|
350
|
+
],
|
|
351
|
+
defaultValue: null,
|
|
352
|
+
config: {},
|
|
353
|
+
searchable: true,
|
|
354
|
+
required: false,
|
|
355
|
+
showInCards: true,
|
|
356
|
+
order: 0,
|
|
357
|
+
archivedAt: null,
|
|
358
|
+
createdAt: iso(80 * DAY),
|
|
359
|
+
updatedAt: iso(80 * DAY),
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
id: uid(785),
|
|
363
|
+
workspaceId: WORKSPACE,
|
|
364
|
+
projectId: null,
|
|
365
|
+
key: 'customer',
|
|
366
|
+
name: 'Customer',
|
|
367
|
+
description: null,
|
|
368
|
+
type: 'text',
|
|
369
|
+
options: [],
|
|
370
|
+
defaultValue: null,
|
|
371
|
+
config: { maxLength: 120 },
|
|
372
|
+
searchable: true,
|
|
373
|
+
required: false,
|
|
374
|
+
showInCards: false,
|
|
375
|
+
order: 1,
|
|
376
|
+
archivedAt: null,
|
|
377
|
+
createdAt: iso(80 * DAY),
|
|
378
|
+
updatedAt: iso(80 * DAY),
|
|
379
|
+
},
|
|
380
|
+
]
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The mock's version of the server's layout resolver.
|
|
384
|
+
*
|
|
385
|
+
* It follows the same two rules, because a demo that behaved differently would teach the wrong
|
|
386
|
+
* thing: an empty layout shows everything, and a field the layout does not name is appended.
|
|
387
|
+
* `bug` carries a real stored layout so the demo shows a customised type next to default ones.
|
|
388
|
+
*/
|
|
389
|
+
const SYSTEM_ROWS: Array<{ id: string; section: 'main' | 'sidebar'; pinned: boolean }> = [
|
|
390
|
+
{ id: 'title', section: 'main', pinned: true },
|
|
391
|
+
{ id: 'description', section: 'main', pinned: false },
|
|
392
|
+
{ id: 'status', section: 'sidebar', pinned: true },
|
|
393
|
+
{ id: 'type', section: 'sidebar', pinned: true },
|
|
394
|
+
{ id: 'assignees', section: 'sidebar', pinned: false },
|
|
395
|
+
{ id: 'priority', section: 'sidebar', pinned: false },
|
|
396
|
+
{ id: 'labels', section: 'sidebar', pinned: false },
|
|
397
|
+
{ id: 'estimate', section: 'sidebar', pinned: false },
|
|
398
|
+
{ id: 'dueDate', section: 'sidebar', pinned: false },
|
|
399
|
+
{ id: 'cycle', section: 'sidebar', pinned: false },
|
|
400
|
+
{ id: 'project', section: 'sidebar', pinned: false },
|
|
401
|
+
]
|
|
402
|
+
|
|
403
|
+
function resolveMockLayout(typeId: string, projectId: string | null): ResolvedLayout {
|
|
404
|
+
const type = types.find((t) => t.id === typeId)
|
|
405
|
+
const stored = new Map((type?.fieldLayout ?? []).map((i) => [i.fieldId, i]))
|
|
406
|
+
const out: Array<ResolvedField & { placedHidden: boolean }> = []
|
|
407
|
+
let order = 0
|
|
408
|
+
|
|
409
|
+
const place = (
|
|
410
|
+
fieldId: string,
|
|
411
|
+
kind: 'system' | 'custom',
|
|
412
|
+
label: string,
|
|
413
|
+
defaultSection: 'main' | 'sidebar',
|
|
414
|
+
pinned: boolean,
|
|
415
|
+
field: FieldDef | null,
|
|
416
|
+
) => {
|
|
417
|
+
const item = stored.get(fieldId)
|
|
418
|
+
const placedHidden = !pinned && (item?.hidden === true || item?.section === 'hidden')
|
|
419
|
+
out.push({
|
|
420
|
+
fieldId,
|
|
421
|
+
kind,
|
|
422
|
+
label,
|
|
423
|
+
section: item?.section === 'main' || item?.section === 'sidebar' ? item.section : defaultSection,
|
|
424
|
+
order: item?.order ?? order,
|
|
425
|
+
required: pinned || item?.required === true || field?.required === true,
|
|
426
|
+
pinned,
|
|
427
|
+
showInCards: field?.showInCards ?? false,
|
|
428
|
+
field,
|
|
429
|
+
placedHidden,
|
|
430
|
+
})
|
|
431
|
+
order += 1
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
for (const row of SYSTEM_ROWS) place(row.id, 'system', row.id, row.section, row.pinned, null)
|
|
435
|
+
for (const field of fields) place(`cf.${field.key}`, 'custom', field.name, 'sidebar', false, field)
|
|
436
|
+
|
|
437
|
+
const sort = (a: ResolvedField, b: ResolvedField) => a.order - b.order
|
|
438
|
+
const shown = ({ placedHidden: _drop, ...f }: ResolvedField & { placedHidden: boolean }) => f
|
|
439
|
+
const pick = (want: (f: (typeof out)[number]) => boolean) => out.filter(want).sort(sort).map(shown)
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
typeId,
|
|
443
|
+
projectId,
|
|
444
|
+
main: pick((f) => !f.placedHidden && f.section === 'main'),
|
|
445
|
+
sidebar: pick((f) => !f.placedHidden && f.section === 'sidebar'),
|
|
446
|
+
hidden: pick((f) => f.placedHidden),
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const EXTRA_FIELDS: FieldDef[] = [
|
|
451
|
+
{
|
|
452
|
+
id: uid(786),
|
|
453
|
+
workspaceId: WORKSPACE,
|
|
454
|
+
projectId: null,
|
|
455
|
+
key: 'caused_by',
|
|
456
|
+
name: 'Caused by',
|
|
457
|
+
description: 'The change that introduced this',
|
|
458
|
+
type: 'relation',
|
|
459
|
+
options: [],
|
|
460
|
+
defaultValue: null,
|
|
461
|
+
config: { relationMultiple: true },
|
|
462
|
+
searchable: false,
|
|
463
|
+
required: false,
|
|
464
|
+
showInCards: false,
|
|
465
|
+
order: 2,
|
|
466
|
+
archivedAt: null,
|
|
467
|
+
createdAt: iso(80 * DAY),
|
|
468
|
+
updatedAt: iso(80 * DAY),
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
id: uid(787),
|
|
472
|
+
workspaceId: WORKSPACE,
|
|
473
|
+
projectId: null,
|
|
474
|
+
key: 'days_open',
|
|
475
|
+
name: 'Days open',
|
|
476
|
+
description: 'Calculated from the dates',
|
|
477
|
+
type: 'formula',
|
|
478
|
+
options: [],
|
|
479
|
+
defaultValue: null,
|
|
480
|
+
config: { formula: 'daysBetween({startDate}, {dueDate})', formulaResult: 'number' },
|
|
481
|
+
searchable: false,
|
|
482
|
+
required: false,
|
|
483
|
+
showInCards: false,
|
|
484
|
+
order: 3,
|
|
485
|
+
archivedAt: null,
|
|
486
|
+
createdAt: iso(80 * DAY),
|
|
487
|
+
updatedAt: iso(80 * DAY),
|
|
488
|
+
},
|
|
489
|
+
]
|
|
490
|
+
fields.push(...EXTRA_FIELDS)
|
|
491
|
+
|
|
492
|
+
// ---------------------------------------------------------------------------------------------
|
|
493
|
+
// issues
|
|
494
|
+
// ---------------------------------------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
/** `[project, type, title, status, priority, assignees, labels, estimate, dueInDays, cycle]` */
|
|
497
|
+
type Seed = [
|
|
498
|
+
number,
|
|
499
|
+
string,
|
|
500
|
+
string,
|
|
501
|
+
string,
|
|
502
|
+
Priority,
|
|
503
|
+
number[],
|
|
504
|
+
number[],
|
|
505
|
+
number | null,
|
|
506
|
+
number | null,
|
|
507
|
+
number | null,
|
|
508
|
+
]
|
|
509
|
+
|
|
510
|
+
const SEEDS: Seed[] = [
|
|
511
|
+
[0, 'bug', 'Voice rooms drop after 20 minutes', 'in_progress', 'urgent', [0], [740, 745], 5, 0, 761],
|
|
512
|
+
[0, 'task', 'Row-level security policies for module schemas', 'in_review', 'high', [1], [741], 8, 1, 761],
|
|
513
|
+
[0, 'story', 'Issue list keyboard navigation', 'in_progress', 'high', [0, 2], [742], 3, 2, 761],
|
|
514
|
+
[0, 'bug', 'Workspace switcher loses focus on Escape', 'todo', 'medium', [2], [742], 1, null, 761],
|
|
515
|
+
[0, 'task', 'Cache effective permissions in Valkey', 'done', 'high', [1], [740, 743], 5, null, 761],
|
|
516
|
+
[0, 'task', 'Audit log export as NDJSON', 'backlog', 'low', [], [], 3, null, null],
|
|
517
|
+
[0, 'bug', 'Invite links expire a day early', 'triage', 'medium', [], [745], null, null, null],
|
|
518
|
+
[0, 'story', 'Saved views with shared visibility', 'todo', 'medium', [3], [], 8, 6, 762],
|
|
519
|
+
[0, 'task', 'Migrate job queue to pg-boss 11', 'in_progress', 'medium', [1], [743], 5, 4, 761],
|
|
520
|
+
[0, 'bug', 'Duplicate notifications on reconnect', 'in_review', 'high', [0], [745], 2, 1, 761],
|
|
521
|
+
[0, 'task', 'Rate limit the public intake form', 'todo', 'high', [1], [741], 2, 3, 761],
|
|
522
|
+
[0, 'story', 'Bulk edit from the issue list', 'backlog', 'medium', [], [], 5, null, null],
|
|
523
|
+
[0, 'task', 'Drop the legacy members endpoint', 'done', 'low', [2], [], 1, null, 760],
|
|
524
|
+
[0, 'bug', 'Avatar initials wrong for single-word names', 'done', 'low', [3], [742], 1, null, 760],
|
|
525
|
+
[0, 'epic', 'Self-hosted install in one command', 'in_progress', 'high', [1], [743], 21, 20, null],
|
|
526
|
+
[0, 'task', 'Postgres 18 upgrade for the dev compose file', 'todo', 'low', [], [743], 2, null, 762],
|
|
527
|
+
[0, 'bug', 'Search returns archived workspaces', 'triage', 'low', [], [], null, null, null],
|
|
528
|
+
[0, 'task', 'Document the module contract lifecycle', 'backlog', 'none', [], [744], 3, null, null],
|
|
529
|
+
[0, 'story', 'Board drag and drop with WIP limits', 'in_progress', 'urgent', [0], [742], 8, 1, 761],
|
|
530
|
+
[0, 'task', 'Ship signed release artefacts', 'cancelled', 'medium', [1], [741], 5, null, 760],
|
|
531
|
+
|
|
532
|
+
[1, 'bug', 'Presence flickers when a tab is backgrounded', 'in_progress', 'high', [2], [740], 3, 2, null],
|
|
533
|
+
[1, 'task', 'Heartbeat interval should back off on failure', 'todo', 'medium', [2], [743], 2, null, null],
|
|
534
|
+
[1, 'story', 'Typing indicators across devices', 'backlog', 'medium', [], [], 5, null, null],
|
|
535
|
+
[
|
|
536
|
+
1,
|
|
537
|
+
'bug',
|
|
538
|
+
'WebSocket reconnect storms after a deploy',
|
|
539
|
+
'in_review',
|
|
540
|
+
'urgent',
|
|
541
|
+
[1, 2],
|
|
542
|
+
[740, 743],
|
|
543
|
+
5,
|
|
544
|
+
0,
|
|
545
|
+
null,
|
|
546
|
+
],
|
|
547
|
+
[1, 'task', 'Publish presence to the workspace channel only', 'done', 'medium', [2], [741], 3, null, null],
|
|
548
|
+
[1, 'task', 'Trim the realtime payload for board updates', 'todo', 'low', [], [740], 2, 9, null],
|
|
549
|
+
[1, 'bug', 'Away status never clears on mobile', 'triage', 'medium', [], [], null, null, null],
|
|
550
|
+
[1, 'story', 'Live cursors in the planner', 'backlog', 'low', [], [742], 8, null, null],
|
|
551
|
+
[1, 'task', 'Load test 5k concurrent sockets', 'todo', 'high', [1], [740], 5, 5, null],
|
|
552
|
+
[1, 'task', 'Retire the polling fallback', 'done', 'low', [2], [], 1, null, null],
|
|
553
|
+
[1, 'bug', 'Presence dot misaligned in RTL', 'todo', 'low', [3], [742], 1, 3, null],
|
|
554
|
+
[1, 'story', 'Huddles from any channel', 'backlog', 'medium', [], [], 13, null, null],
|
|
555
|
+
|
|
556
|
+
[2, 'task', 'Write the KQL reference page', 'in_progress', 'medium', [3], [744], 5, 3, null],
|
|
557
|
+
[2, 'bug', 'Code blocks lose language on paste', 'todo', 'low', [3], [], 2, null, null],
|
|
558
|
+
[2, 'story', 'Publish docs to a public site', 'backlog', 'medium', [], [], 8, null, null],
|
|
559
|
+
[2, 'task', 'Persian translation pass for the shell', 'in_review', 'high', [3], [742], 3, 1, null],
|
|
560
|
+
[2, 'task', 'Screenshot the board for the README', 'done', 'low', [0], [], 1, null, null],
|
|
561
|
+
[2, 'bug', 'Embedded issue cards break in dark mode', 'triage', 'medium', [], [742], null, null, null],
|
|
562
|
+
[2, 'task', 'Contributing guide for module authors', 'todo', 'medium', [1], [744], 3, 8, null],
|
|
563
|
+
[2, 'story', 'Versioned docs per release', 'backlog', 'low', [], [], 8, null, null],
|
|
564
|
+
[2, 'task', 'Alt text for every diagram', 'todo', 'low', [], [744, 742], 2, 12, null],
|
|
565
|
+
[2, 'bug', 'Search index misses headings', 'done', 'medium', [3], [], 2, null, null],
|
|
566
|
+
]
|
|
567
|
+
|
|
568
|
+
const emptyRelations = () => ({
|
|
569
|
+
blocks: 0,
|
|
570
|
+
blockedBy: 0,
|
|
571
|
+
openBlockers: 0,
|
|
572
|
+
relates: 0,
|
|
573
|
+
duplicates: 0,
|
|
574
|
+
subItems: 0,
|
|
575
|
+
subItemsDone: 0,
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
function buildIssues(): Issue[] {
|
|
579
|
+
const ranks = rankSequence(SEEDS.length)
|
|
580
|
+
const counters = new Map<string, number>()
|
|
581
|
+
return SEEDS.map((seed, i) => {
|
|
582
|
+
const [p, typeKey, title, statusId, priority, assignees, labelIds, estimate, dueIn, cycle] = seed
|
|
583
|
+
const proj = projects[p] as Project
|
|
584
|
+
const number = (counters.get(proj.id) ?? 0) + 1
|
|
585
|
+
counters.set(proj.id, number)
|
|
586
|
+
const status = statusById.get(statusId) as StatusInfo
|
|
587
|
+
const type = types.find((t) => t.key === typeKey) as WorkItemType
|
|
588
|
+
const done = status.category === 'done'
|
|
589
|
+
const cancelled = status.category === 'cancelled'
|
|
590
|
+
return {
|
|
591
|
+
id: uid(800 + i),
|
|
592
|
+
workspaceId: WORKSPACE,
|
|
593
|
+
projectId: proj.id,
|
|
594
|
+
key: `${proj.key}-${number}`,
|
|
595
|
+
number,
|
|
596
|
+
typeId: type.id,
|
|
597
|
+
title,
|
|
598
|
+
description: {
|
|
599
|
+
type: 'doc',
|
|
600
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: describe(title, typeKey) }] }],
|
|
601
|
+
},
|
|
602
|
+
descriptionText: describe(title, typeKey),
|
|
603
|
+
statusId: status.id,
|
|
604
|
+
statusCategory: status.category,
|
|
605
|
+
priority,
|
|
606
|
+
assigneeIds: assignees.map((a) => who(a)),
|
|
607
|
+
reporterId: who(i + 1),
|
|
608
|
+
creatorId: who(i + 2),
|
|
609
|
+
labelIds: labelIds.map((n) => uid(n)),
|
|
610
|
+
// the first project's work is spread over its two components; the rest carry none
|
|
611
|
+
componentIds: p === 0 ? [uid(i % 2 === 0 ? 2200 : 2201)] : p === 1 && i % 3 === 0 ? [uid(2202)] : [],
|
|
612
|
+
versionIds: [],
|
|
613
|
+
affectsVersionIds: [],
|
|
614
|
+
cycleId: cycle ? uid(cycle) : null,
|
|
615
|
+
milestoneId: p === 0 && i % 3 === 0 ? uid(770) : p === 1 && i % 2 === 0 ? uid(771) : null,
|
|
616
|
+
parentId: null,
|
|
617
|
+
rank: ranks[i] as string,
|
|
618
|
+
estimate,
|
|
619
|
+
estimateUnit: 'points',
|
|
620
|
+
startDate: null,
|
|
621
|
+
dueDate: dueIn === null ? null : day(dueIn),
|
|
622
|
+
completedAt: done ? iso((i % 7) * DAY) : null,
|
|
623
|
+
cancelledAt: cancelled ? iso(5 * DAY) : null,
|
|
624
|
+
resolution: done ? 'fixed' : cancelled ? 'wontfix' : null,
|
|
625
|
+
// A `select` stores an option *id*, not its label. Storing the label made the list look
|
|
626
|
+
// right by accident and left the board — which builds its columns from the option ids —
|
|
627
|
+
// with nothing in any of them.
|
|
628
|
+
custom: typeKey === 'bug' ? { severity: i % 4 === 0 ? uid(781) : uid(783) } : {},
|
|
629
|
+
watcherIds: i % 4 === 0 ? [ME] : [],
|
|
630
|
+
subscriberCount: assignees.length + 1,
|
|
631
|
+
commentCount: 0,
|
|
632
|
+
attachmentCount: 0,
|
|
633
|
+
relationSummary: emptyRelations(),
|
|
634
|
+
timeSpentSec: done ? 3600 * ((i % 5) + 1) : i % 3 === 0 ? 3600 * 2 : 0,
|
|
635
|
+
remainingSec: null,
|
|
636
|
+
originalEstimateSec: null,
|
|
637
|
+
sla: null,
|
|
638
|
+
triage: status.category === 'triage',
|
|
639
|
+
snoozedUntil: null,
|
|
640
|
+
source: status.category === 'triage' ? 'intake' : 'app',
|
|
641
|
+
externalRef: null,
|
|
642
|
+
chatChannelId: null,
|
|
643
|
+
archivedAt: null,
|
|
644
|
+
createdAt: iso((SEEDS.length - i + 2) * DAY),
|
|
645
|
+
updatedAt: iso((i % 9) * 3600e3 + 600e3),
|
|
646
|
+
lastActivityAt: iso((i % 9) * 3600e3 + 600e3),
|
|
647
|
+
} satisfies Issue
|
|
648
|
+
})
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function describe(title: string, typeKey: string): string {
|
|
652
|
+
return typeKey === 'bug'
|
|
653
|
+
? `Reproduced on the current main branch. ${title} — the fix likely belongs in the module that owns the surface, not the shell.`
|
|
654
|
+
: `${title}. Scope it small enough to land in one cycle and leave the contract unchanged where possible.`
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---------------------------------------------------------------------------------------------
|
|
658
|
+
// KQL evaluation
|
|
659
|
+
// ---------------------------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
type Scalar = string | number | boolean | null
|
|
662
|
+
|
|
663
|
+
/** Field accessor used by the KQL evaluator, mirroring `SYSTEM_FIELDS`. */
|
|
664
|
+
function fieldValue(issue: Issue, name: string): Scalar | Scalar[] {
|
|
665
|
+
switch (name.toLowerCase()) {
|
|
666
|
+
case 'key':
|
|
667
|
+
return issue.key
|
|
668
|
+
case 'project':
|
|
669
|
+
return issue.projectId
|
|
670
|
+
case 'type':
|
|
671
|
+
return issue.typeId
|
|
672
|
+
case 'title':
|
|
673
|
+
return issue.title
|
|
674
|
+
case 'status':
|
|
675
|
+
return issue.statusId
|
|
676
|
+
case 'statuscategory':
|
|
677
|
+
return issue.statusCategory
|
|
678
|
+
case 'priority':
|
|
679
|
+
return issue.priority
|
|
680
|
+
case 'assignee':
|
|
681
|
+
return issue.assigneeIds
|
|
682
|
+
case 'reporter':
|
|
683
|
+
return issue.reporterId
|
|
684
|
+
case 'label':
|
|
685
|
+
return issue.labelIds
|
|
686
|
+
case 'component':
|
|
687
|
+
return issue.componentIds
|
|
688
|
+
case 'version':
|
|
689
|
+
return issue.versionIds
|
|
690
|
+
case 'cycle':
|
|
691
|
+
return issue.cycleId
|
|
692
|
+
case 'milestone':
|
|
693
|
+
return issue.milestoneId
|
|
694
|
+
case 'parent':
|
|
695
|
+
// The server resolves an issue key to its id before comparing (`parent = "KRN-12"`), so the
|
|
696
|
+
// mock answers with the key as well as the id — otherwise a query that works against the
|
|
697
|
+
// real backend silently matches nothing here.
|
|
698
|
+
return issue.parentId ? [issue.parentId, keyOfIssue(issue.parentId)] : null
|
|
699
|
+
case 'created':
|
|
700
|
+
return issue.createdAt
|
|
701
|
+
case 'updated':
|
|
702
|
+
return issue.updatedAt
|
|
703
|
+
case 'completed':
|
|
704
|
+
return issue.completedAt
|
|
705
|
+
case 'due':
|
|
706
|
+
return issue.dueDate
|
|
707
|
+
case 'start':
|
|
708
|
+
return issue.startDate
|
|
709
|
+
case 'estimate':
|
|
710
|
+
return issue.estimate
|
|
711
|
+
case 'timespent':
|
|
712
|
+
return issue.timeSpentSec
|
|
713
|
+
case 'watcher':
|
|
714
|
+
return issue.watcherIds
|
|
715
|
+
case 'resolution':
|
|
716
|
+
return issue.resolution
|
|
717
|
+
case 'triage':
|
|
718
|
+
return issue.triage
|
|
719
|
+
case 'archived':
|
|
720
|
+
return issue.archivedAt !== null
|
|
721
|
+
case 'text':
|
|
722
|
+
return `${issue.key} ${issue.title} ${issue.descriptionText}`
|
|
723
|
+
default:
|
|
724
|
+
return name.startsWith('cf.') ? ((issue.custom[name.slice(3)] ?? null) as Scalar) : null
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Every spelling a value may be written as: an id, a key, a name or a person's handle. */
|
|
729
|
+
function aliasesFor(value: string): string[] {
|
|
730
|
+
const out = [value]
|
|
731
|
+
const proj = projects.find((p) => p.id === value)
|
|
732
|
+
if (proj) out.push(proj.key, proj.name)
|
|
733
|
+
const type = types.find((t) => t.id === value)
|
|
734
|
+
if (type) out.push(type.key, type.name)
|
|
735
|
+
const lbl = labels.find((l) => l.id === value)
|
|
736
|
+
if (lbl) out.push(lbl.name)
|
|
737
|
+
const cyc = cycles.find((c) => c.id === value)
|
|
738
|
+
if (cyc) out.push(cyc.name, String(cyc.number))
|
|
739
|
+
const ms = milestones.find((m) => m.id === value)
|
|
740
|
+
if (ms) out.push(ms.name)
|
|
741
|
+
const status = statusById.get(value)
|
|
742
|
+
if (status) out.push(status.name)
|
|
743
|
+
const person = PERSON_NAMES[value]
|
|
744
|
+
if (person) out.push(person, person.split(' ')[0] as string)
|
|
745
|
+
return out.map((s) => s.toLowerCase())
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function literal(v: KqlValue): Scalar {
|
|
749
|
+
switch (v.kind) {
|
|
750
|
+
case 'string':
|
|
751
|
+
case 'ident':
|
|
752
|
+
case 'date':
|
|
753
|
+
return v.value
|
|
754
|
+
case 'number':
|
|
755
|
+
return v.value
|
|
756
|
+
case 'bool':
|
|
757
|
+
return v.value
|
|
758
|
+
case 'reldate': {
|
|
759
|
+
const per = { h: 3600e3, d: DAY, w: 7 * DAY, m: 30 * DAY, y: 365 * DAY }[v.unit]
|
|
760
|
+
return new Date(NOW + v.amount * per).toISOString()
|
|
761
|
+
}
|
|
762
|
+
case 'func':
|
|
763
|
+
return v.name === 'currentUser' ? ME : v.name === 'now' ? new Date(NOW).toISOString() : null
|
|
764
|
+
default:
|
|
765
|
+
return null
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function equals(actual: Scalar | Scalar[], expected: Scalar): boolean {
|
|
770
|
+
if (expected === null) return actual === null || (Array.isArray(actual) && actual.length === 0)
|
|
771
|
+
const wanted = String(expected).toLowerCase()
|
|
772
|
+
const candidates = Array.isArray(actual) ? actual : [actual]
|
|
773
|
+
return candidates.some((c) => c !== null && aliasesFor(String(c)).includes(wanted))
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function compare(actual: Scalar | Scalar[], expected: Scalar): number {
|
|
777
|
+
const a = Array.isArray(actual) ? (actual[0] ?? null) : actual
|
|
778
|
+
if (a === null || expected === null) return Number.NaN
|
|
779
|
+
if (typeof a === 'number' || typeof expected === 'number') return Number(a) - Number(expected)
|
|
780
|
+
return String(a) < String(expected) ? -1 : String(a) > String(expected) ? 1 : 0
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** The key of an issue by id, for KQL comparisons that name an issue the way people do. */
|
|
784
|
+
function keyOfIssue(id: string): string {
|
|
785
|
+
return issueKeyById.get(id) ?? id
|
|
786
|
+
}
|
|
787
|
+
const issueKeyById = new Map<string, string>()
|
|
788
|
+
|
|
789
|
+
function matches(issue: Issue, expr: KqlExpr | null): boolean {
|
|
790
|
+
if (!expr) return true
|
|
791
|
+
switch (expr.kind) {
|
|
792
|
+
case 'and':
|
|
793
|
+
return expr.children.every((c) => matches(issue, c))
|
|
794
|
+
case 'or':
|
|
795
|
+
return expr.children.some((c) => matches(issue, c))
|
|
796
|
+
case 'not':
|
|
797
|
+
return !matches(issue, expr.child)
|
|
798
|
+
case 'cmp': {
|
|
799
|
+
const actual = fieldValue(issue, expr.field)
|
|
800
|
+
const empty = actual === null || actual === '' || (Array.isArray(actual) && actual.length === 0)
|
|
801
|
+
switch (expr.op) {
|
|
802
|
+
case 'is-empty':
|
|
803
|
+
return empty
|
|
804
|
+
case 'is-not-empty':
|
|
805
|
+
return !empty
|
|
806
|
+
case 'in':
|
|
807
|
+
return (expr.values ?? []).some((v) => equals(actual, literal(v)))
|
|
808
|
+
case 'not-in':
|
|
809
|
+
return !(expr.values ?? []).some((v) => equals(actual, literal(v)))
|
|
810
|
+
case '=':
|
|
811
|
+
return equals(actual, expr.value ? literal(expr.value) : null)
|
|
812
|
+
case '!=':
|
|
813
|
+
return !equals(actual, expr.value ? literal(expr.value) : null)
|
|
814
|
+
case '~':
|
|
815
|
+
case '!~': {
|
|
816
|
+
const needle = String(expr.value ? literal(expr.value) : '').toLowerCase()
|
|
817
|
+
const hay = (Array.isArray(actual) ? actual.join(' ') : String(actual ?? '')).toLowerCase()
|
|
818
|
+
const hit = needle.length > 0 && hay.includes(needle)
|
|
819
|
+
return expr.op === '~' ? hit : !hit
|
|
820
|
+
}
|
|
821
|
+
default: {
|
|
822
|
+
const d = compare(actual, expr.value ? literal(expr.value) : null)
|
|
823
|
+
if (Number.isNaN(d)) return false
|
|
824
|
+
return expr.op === '<' ? d < 0 : expr.op === '<=' ? d <= 0 : expr.op === '>' ? d > 0 : d >= 0
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// ---------------------------------------------------------------------------------------------
|
|
832
|
+
// the client
|
|
833
|
+
// ---------------------------------------------------------------------------------------------
|
|
834
|
+
|
|
835
|
+
/** Every workspace-scoped procedure carries the workspace; the mock has one, so it only checks shape. */
|
|
836
|
+
type Ws = { workspaceId: string }
|
|
837
|
+
|
|
838
|
+
const RICH = (text: string) => ({
|
|
839
|
+
type: 'doc' as const,
|
|
840
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
export function createMockTrackerApi() {
|
|
844
|
+
const state = {
|
|
845
|
+
issues: buildIssues(),
|
|
846
|
+
comments: [] as Comment[],
|
|
847
|
+
attachments: [] as Attachment[],
|
|
848
|
+
relations: [] as RelationView[],
|
|
849
|
+
views: [] as View[],
|
|
850
|
+
// Seeded and then mutable: the planning screen creates, starts, completes and deletes these,
|
|
851
|
+
// and a demo that resets them on every render cannot show a cycle being closed.
|
|
852
|
+
cycles: cycles.map((c) => ({ ...c })),
|
|
853
|
+
milestones: milestones.map((ms) => ({ ...ms })),
|
|
854
|
+
components: components.map((c) => ({ ...c })),
|
|
855
|
+
versions: [] as Version[],
|
|
856
|
+
worklogs: [] as Worklog[],
|
|
857
|
+
issueTemplates: [] as IssueTemplate[],
|
|
858
|
+
recurring: [] as RecurringIssue[],
|
|
859
|
+
// One finished import, so the history is something the demo shows rather than something that
|
|
860
|
+
// only appears after you run one yourself.
|
|
861
|
+
imports: [
|
|
862
|
+
{
|
|
863
|
+
id: uid(2900),
|
|
864
|
+
workspaceId: WORKSPACE,
|
|
865
|
+
projectId: uid(700),
|
|
866
|
+
source: 'csv',
|
|
867
|
+
fileId: uid(2901),
|
|
868
|
+
mapping: { Summary: 'title', Priority: 'priority' },
|
|
869
|
+
status: 'completed',
|
|
870
|
+
progress: { total: 214, processed: 214, created: 211, skipped: 0, failed: 3 },
|
|
871
|
+
errors: [
|
|
872
|
+
{ row: 42, message: 'Priority "Blocker" is not one of urgent, high, medium, low' },
|
|
873
|
+
{ row: 118, message: 'Title is empty' },
|
|
874
|
+
{ row: 190, message: 'Title is empty' },
|
|
875
|
+
],
|
|
876
|
+
createdBy: ME,
|
|
877
|
+
startedAt: iso(9 * DAY),
|
|
878
|
+
finishedAt: iso(9 * DAY - 40_000),
|
|
879
|
+
createdAt: iso(9 * DAY),
|
|
880
|
+
},
|
|
881
|
+
] as ImportJob[],
|
|
882
|
+
workflows: [
|
|
883
|
+
{
|
|
884
|
+
id: WORKFLOW_ID,
|
|
885
|
+
workspaceId: WORKSPACE,
|
|
886
|
+
projectId: null,
|
|
887
|
+
name: 'Software',
|
|
888
|
+
description: 'The workflow every demo project uses',
|
|
889
|
+
definition: {
|
|
890
|
+
id: WORKFLOW_ID,
|
|
891
|
+
name: 'Software',
|
|
892
|
+
statuses: statuses.map((s, order) => ({
|
|
893
|
+
id: s.id,
|
|
894
|
+
name: s.name,
|
|
895
|
+
category: s.category,
|
|
896
|
+
order,
|
|
897
|
+
...(s.id === 'backlog' ? { initial: true } : {}),
|
|
898
|
+
})),
|
|
899
|
+
// Every array present: the contract requires them, and a transition missing
|
|
900
|
+
// `validators` is not "no validators" to the type, it is an incomplete transition.
|
|
901
|
+
transitions: [
|
|
902
|
+
{
|
|
903
|
+
id: 'plan',
|
|
904
|
+
name: 'Plan',
|
|
905
|
+
from: ['backlog'],
|
|
906
|
+
to: 'todo',
|
|
907
|
+
conditions: [],
|
|
908
|
+
validators: [],
|
|
909
|
+
postFunctions: [],
|
|
910
|
+
hidden: false,
|
|
911
|
+
},
|
|
912
|
+
{
|
|
913
|
+
id: 'start',
|
|
914
|
+
name: 'Start',
|
|
915
|
+
from: ['backlog', 'todo'],
|
|
916
|
+
to: 'in_progress',
|
|
917
|
+
conditions: [],
|
|
918
|
+
validators: [],
|
|
919
|
+
postFunctions: [{ type: 'assign.to', config: { to: 'currentUser' } }],
|
|
920
|
+
hidden: false,
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
id: 'review',
|
|
924
|
+
name: 'Ready for review',
|
|
925
|
+
from: ['in_progress'],
|
|
926
|
+
to: 'in_review',
|
|
927
|
+
conditions: [],
|
|
928
|
+
validators: [{ type: 'comment.required' }],
|
|
929
|
+
postFunctions: [],
|
|
930
|
+
hidden: false,
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
id: 'complete',
|
|
934
|
+
name: 'Done',
|
|
935
|
+
from: ['in_progress', 'in_review'],
|
|
936
|
+
to: 'done',
|
|
937
|
+
conditions: [{ type: 'subtasks.allDone' }],
|
|
938
|
+
validators: [],
|
|
939
|
+
postFunctions: [{ type: 'resolution.set', config: { value: 'done' } }],
|
|
940
|
+
hidden: false,
|
|
941
|
+
},
|
|
942
|
+
],
|
|
943
|
+
},
|
|
944
|
+
isDefault: true,
|
|
945
|
+
archivedAt: null,
|
|
946
|
+
createdAt: iso(120 * DAY),
|
|
947
|
+
updatedAt: iso(120 * DAY),
|
|
948
|
+
},
|
|
949
|
+
] as Workflow[],
|
|
950
|
+
timer: null as Timer | null,
|
|
951
|
+
links: [] as Link[],
|
|
952
|
+
approvals: [] as IssueApproval[],
|
|
953
|
+
history: [] as IssueHistoryEntry[],
|
|
954
|
+
counters: new Map<string, number>(),
|
|
955
|
+
}
|
|
956
|
+
for (const issue of state.issues) {
|
|
957
|
+
state.counters.set(issue.projectId, Math.max(state.counters.get(issue.projectId) ?? 0, issue.number))
|
|
958
|
+
issueKeyById.set(issue.id, issue.key)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
const seedComment = (issueIndex: number, authorIdx: number, text: string, minutesAgo: number) => {
|
|
962
|
+
const issue = state.issues[issueIndex] as Issue
|
|
963
|
+
state.comments.push({
|
|
964
|
+
id: uid(900 + state.comments.length),
|
|
965
|
+
workspaceId: WORKSPACE,
|
|
966
|
+
issueId: issue.id,
|
|
967
|
+
parentId: null,
|
|
968
|
+
authorId: who(authorIdx),
|
|
969
|
+
body: RICH(text),
|
|
970
|
+
bodyText: text,
|
|
971
|
+
mentionIds: [],
|
|
972
|
+
reactions:
|
|
973
|
+
state.comments.length % 3 === 0 ? [{ emoji: '👍', count: 2, userIds: [who(0), who(2)] }] : [],
|
|
974
|
+
internal: false,
|
|
975
|
+
source: 'app',
|
|
976
|
+
replyCount: 0,
|
|
977
|
+
editedAt: null,
|
|
978
|
+
deletedAt: null,
|
|
979
|
+
createdAt: iso(minutesAgo * 60e3),
|
|
980
|
+
updatedAt: iso(minutesAgo * 60e3),
|
|
981
|
+
})
|
|
982
|
+
issue.commentCount += 1
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
seedComment(
|
|
986
|
+
0,
|
|
987
|
+
1,
|
|
988
|
+
'Reproduced on staging — the socket closes at exactly 20 minutes, so it smells like an idle timeout on the proxy rather than our heartbeat.',
|
|
989
|
+
240,
|
|
990
|
+
)
|
|
991
|
+
seedComment(0, 2, 'Agreed. I bumped the proxy read timeout locally and it survived an hour.', 180)
|
|
992
|
+
seedComment(
|
|
993
|
+
0,
|
|
994
|
+
0,
|
|
995
|
+
'Let us keep the heartbeat anyway; it protects us from every other proxy we do not control.',
|
|
996
|
+
45,
|
|
997
|
+
)
|
|
998
|
+
seedComment(
|
|
999
|
+
2,
|
|
1000
|
+
3,
|
|
1001
|
+
'j and k for up and down, x to select, enter to open. Anything else and we are inventing a new language.',
|
|
1002
|
+
600,
|
|
1003
|
+
)
|
|
1004
|
+
seedComment(2, 0, 'Works for me. Escape closes the panel and returns focus to the row.', 90)
|
|
1005
|
+
seedComment(
|
|
1006
|
+
18,
|
|
1007
|
+
1,
|
|
1008
|
+
'WIP limits should warn rather than block — a hard stop just makes people rename columns.',
|
|
1009
|
+
320,
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
for (const [i, issue] of state.issues.entries()) {
|
|
1013
|
+
if (i % 5 !== 0) continue
|
|
1014
|
+
state.history.push({
|
|
1015
|
+
id: uid(950 + i),
|
|
1016
|
+
issueId: issue.id,
|
|
1017
|
+
actorId: who(i),
|
|
1018
|
+
action: 'created',
|
|
1019
|
+
changes: [],
|
|
1020
|
+
data: {},
|
|
1021
|
+
occurredAt: issue.createdAt,
|
|
1022
|
+
})
|
|
1023
|
+
state.history.push({
|
|
1024
|
+
id: uid(951 + i),
|
|
1025
|
+
issueId: issue.id,
|
|
1026
|
+
actorId: who(i + 1),
|
|
1027
|
+
action: 'status_changed',
|
|
1028
|
+
changes: [{ field: 'status', from: 'todo', to: issue.statusId }],
|
|
1029
|
+
data: {},
|
|
1030
|
+
occurredAt: issue.updatedAt,
|
|
1031
|
+
})
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const find = (issueId: string) => {
|
|
1035
|
+
const issue = state.issues.find((i) => i.id === issueId)
|
|
1036
|
+
if (!issue) throw new Error(`[mock] unknown issue ${issueId}`)
|
|
1037
|
+
return issue
|
|
1038
|
+
}
|
|
1039
|
+
const touch = (issue: Issue) => {
|
|
1040
|
+
issue.updatedAt = new Date().toISOString()
|
|
1041
|
+
issue.lastActivityAt = issue.updatedAt
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const sortKey = (issue: Issue, field: string): string | number => {
|
|
1045
|
+
switch (field) {
|
|
1046
|
+
case 'priority':
|
|
1047
|
+
return -['none', 'low', 'medium', 'high', 'urgent'].indexOf(issue.priority)
|
|
1048
|
+
case 'updated':
|
|
1049
|
+
return -new Date(issue.updatedAt).getTime()
|
|
1050
|
+
case 'created':
|
|
1051
|
+
return -new Date(issue.createdAt).getTime()
|
|
1052
|
+
case 'due':
|
|
1053
|
+
return issue.dueDate ?? '9999-12-31'
|
|
1054
|
+
case 'estimate':
|
|
1055
|
+
return issue.estimate ?? Number.MAX_SAFE_INTEGER
|
|
1056
|
+
case 'key':
|
|
1057
|
+
return issue.key
|
|
1058
|
+
default:
|
|
1059
|
+
return issue.rank
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
return {
|
|
1064
|
+
projects: {
|
|
1065
|
+
list: async ({ includeArchived }: Ws & { includeArchived?: boolean }) =>
|
|
1066
|
+
clone(
|
|
1067
|
+
projects
|
|
1068
|
+
.filter((p) => includeArchived || !p.archivedAt)
|
|
1069
|
+
// Counted, not seeded: a static count drifts the moment the demo creates an issue,
|
|
1070
|
+
// and a row whose only number is always zero says nothing.
|
|
1071
|
+
.map((p) => ({
|
|
1072
|
+
...p,
|
|
1073
|
+
openIssueCount: state.issues.filter(
|
|
1074
|
+
(i) =>
|
|
1075
|
+
i.projectId === p.id && i.statusCategory !== 'done' && i.statusCategory !== 'cancelled',
|
|
1076
|
+
).length,
|
|
1077
|
+
})),
|
|
1078
|
+
),
|
|
1079
|
+
get: async ({ projectId }: Ws & { projectId: string }) =>
|
|
1080
|
+
clone(projects.find((p) => p.id === projectId) ?? (projects[0] as Project)),
|
|
1081
|
+
getByKey: async ({ key }: Ws & { key: string }) =>
|
|
1082
|
+
clone(projects.find((p) => p.key === key) ?? (projects[0] as Project)),
|
|
1083
|
+
create: async (
|
|
1084
|
+
input: Ws & { name: string; key: string; description?: string | null; template?: string },
|
|
1085
|
+
) => {
|
|
1086
|
+
if (projects.some((p) => p.key === input.key.toUpperCase()))
|
|
1087
|
+
throw new Error(`[mock] the key "${input.key}" is already used`)
|
|
1088
|
+
const now = new Date().toISOString()
|
|
1089
|
+
const project: Project = {
|
|
1090
|
+
...(projects[0] as Project),
|
|
1091
|
+
id: uid(1900 + projects.length),
|
|
1092
|
+
key: input.key.toUpperCase(),
|
|
1093
|
+
name: input.name,
|
|
1094
|
+
description: input.description ?? null,
|
|
1095
|
+
issueCounter: 0,
|
|
1096
|
+
createdAt: now,
|
|
1097
|
+
updatedAt: now,
|
|
1098
|
+
}
|
|
1099
|
+
projects.push(project)
|
|
1100
|
+
state.counters.set(project.id, 0)
|
|
1101
|
+
return clone(project)
|
|
1102
|
+
},
|
|
1103
|
+
update: async ({ projectId, patch }: Ws & { projectId: string; patch: Partial<Project> }) => {
|
|
1104
|
+
const project = projects.find((p) => p.id === projectId)
|
|
1105
|
+
if (!project) throw new Error(`[mock] unknown project ${projectId}`)
|
|
1106
|
+
Object.assign(project, patch, { updatedAt: new Date().toISOString() })
|
|
1107
|
+
return clone(project)
|
|
1108
|
+
},
|
|
1109
|
+
archive: async ({ projectId, archived }: Ws & { projectId: string; archived?: boolean }) => {
|
|
1110
|
+
const project = projects.find((p) => p.id === projectId)
|
|
1111
|
+
if (!project) throw new Error(`[mock] unknown project ${projectId}`)
|
|
1112
|
+
project.archivedAt = archived === false ? null : new Date().toISOString()
|
|
1113
|
+
return clone(project)
|
|
1114
|
+
},
|
|
1115
|
+
delete: async ({ projectId }: Ws & { projectId: string }) => {
|
|
1116
|
+
const at = projects.findIndex((p) => p.id === projectId)
|
|
1117
|
+
if (at < 0) throw new Error(`[mock] unknown project ${projectId}`)
|
|
1118
|
+
projects.splice(at, 1)
|
|
1119
|
+
// Everything that belonged to it goes with it, which is what makes this irreversible.
|
|
1120
|
+
state.issues = state.issues.filter((i) => i.projectId !== projectId)
|
|
1121
|
+
return { ok: true as const }
|
|
1122
|
+
},
|
|
1123
|
+
setIntake: async ({
|
|
1124
|
+
enabled,
|
|
1125
|
+
rotate,
|
|
1126
|
+
}: Ws & { projectId: string; enabled: boolean; rotate?: boolean }) => ({
|
|
1127
|
+
token: enabled ? (rotate ? 'demo-intake-token-2' : 'demo-intake-token') : null,
|
|
1128
|
+
}),
|
|
1129
|
+
templates: {
|
|
1130
|
+
// The four the tracker ships with. The mock does not apply them — it has no schema to seed —
|
|
1131
|
+
// but the chooser is what this exists for, and it must offer what the server offers.
|
|
1132
|
+
list: async (_input: Ws) =>
|
|
1133
|
+
[
|
|
1134
|
+
{ key: 'software', name: 'Software', description: 'Epics, stories, bugs, and a review step.' },
|
|
1135
|
+
{
|
|
1136
|
+
key: 'support',
|
|
1137
|
+
name: 'Support desk',
|
|
1138
|
+
description: 'Tickets with a customer, an impact and a clock.',
|
|
1139
|
+
},
|
|
1140
|
+
{
|
|
1141
|
+
key: 'marketing',
|
|
1142
|
+
name: 'Marketing',
|
|
1143
|
+
description: 'Campaigns, assets and requests, with a publish date.',
|
|
1144
|
+
},
|
|
1145
|
+
{ key: 'simple', name: 'Simple task list', description: 'Tasks. Nothing to learn first.' },
|
|
1146
|
+
].map((t) => ({
|
|
1147
|
+
id: t.key,
|
|
1148
|
+
workspaceId: null,
|
|
1149
|
+
key: t.key,
|
|
1150
|
+
name: t.name,
|
|
1151
|
+
description: t.description,
|
|
1152
|
+
icon: null,
|
|
1153
|
+
body: { version: 1, workflows: [], fields: [], types: [], labels: [], views: [] },
|
|
1154
|
+
builtin: true,
|
|
1155
|
+
createdAt: iso(0),
|
|
1156
|
+
})),
|
|
1157
|
+
},
|
|
1158
|
+
},
|
|
1159
|
+
|
|
1160
|
+
types: {
|
|
1161
|
+
list: async ({ includeArchived }: Ws & { includeArchived?: boolean }) =>
|
|
1162
|
+
clone(types.filter((t) => includeArchived || !t.archivedAt)),
|
|
1163
|
+
layout: async ({ id, projectId }: Ws & { id: string; projectId?: string | null }) =>
|
|
1164
|
+
clone(resolveMockLayout(id, projectId ?? null)),
|
|
1165
|
+
create: async ({ key, name, ...rest }: Ws & { key: string; name: string } & Partial<WorkItemType>) => {
|
|
1166
|
+
if (types.some((t) => t.key === key)) throw new Error(`[mock] type ${key} already exists`)
|
|
1167
|
+
const type: WorkItemType = {
|
|
1168
|
+
...workItemType(724 + types.length, key, name, 'square-check-big', 0),
|
|
1169
|
+
...rest,
|
|
1170
|
+
}
|
|
1171
|
+
// Only one type is the default, here as on the server.
|
|
1172
|
+
if (type.isDefault) for (const other of types) other.isDefault = false
|
|
1173
|
+
types.push(type)
|
|
1174
|
+
return clone(type)
|
|
1175
|
+
},
|
|
1176
|
+
update: async ({ id, patch }: Ws & { id: string; patch: Partial<WorkItemType> }) => {
|
|
1177
|
+
const type = types.find((t) => t.id === id)
|
|
1178
|
+
if (!type) throw new Error(`[mock] unknown type ${id}`)
|
|
1179
|
+
if (patch.isDefault) for (const other of types) other.isDefault = false
|
|
1180
|
+
Object.assign(type, patch, { updatedAt: new Date().toISOString() })
|
|
1181
|
+
return clone(type)
|
|
1182
|
+
},
|
|
1183
|
+
hierarchyRules: async (_input: Ws) => clone(hierarchyRules),
|
|
1184
|
+
setHierarchyRules: async ({ rules }: Ws & { rules: typeof hierarchyRules }) => {
|
|
1185
|
+
Object.assign(hierarchyRules, rules)
|
|
1186
|
+
return clone(hierarchyRules)
|
|
1187
|
+
},
|
|
1188
|
+
archive: async ({ id, archived }: Ws & { id: string; archived?: boolean }) => {
|
|
1189
|
+
const type = types.find((t) => t.id === id)
|
|
1190
|
+
if (!type) throw new Error(`[mock] unknown type ${id}`)
|
|
1191
|
+
if (type.isDefault && archived !== false)
|
|
1192
|
+
throw new Error('[mock] the default type cannot be archived')
|
|
1193
|
+
type.archivedAt = archived === false ? null : new Date().toISOString()
|
|
1194
|
+
return clone(type)
|
|
1195
|
+
},
|
|
1196
|
+
},
|
|
1197
|
+
labels: {
|
|
1198
|
+
list: async ({ projectId }: Ws & { projectId?: string }) =>
|
|
1199
|
+
clone(labels.filter((l) => !projectId || !l.projectId || l.projectId === projectId)),
|
|
1200
|
+
create: async ({ projectId, name }: Ws & { projectId?: string; name: string }) => {
|
|
1201
|
+
const label: Label = {
|
|
1202
|
+
id: uid(2100 + labels.length),
|
|
1203
|
+
workspaceId: WORKSPACE,
|
|
1204
|
+
projectId: projectId ?? null,
|
|
1205
|
+
name,
|
|
1206
|
+
color: null,
|
|
1207
|
+
description: null,
|
|
1208
|
+
groupName: null,
|
|
1209
|
+
issueCount: 0,
|
|
1210
|
+
archivedAt: null,
|
|
1211
|
+
createdAt: new Date().toISOString(),
|
|
1212
|
+
}
|
|
1213
|
+
labels.push(label)
|
|
1214
|
+
return clone(label)
|
|
1215
|
+
},
|
|
1216
|
+
update: async ({ id, patch }: Ws & { id: string; patch: Partial<Label> }) => {
|
|
1217
|
+
const label = labels.find((l) => l.id === id)
|
|
1218
|
+
if (!label) throw new Error(`[mock] unknown label ${id}`)
|
|
1219
|
+
Object.assign(label, patch)
|
|
1220
|
+
return clone(label)
|
|
1221
|
+
},
|
|
1222
|
+
delete: async ({ id }: Ws & { id: string }) => {
|
|
1223
|
+
const index = labels.findIndex((l) => l.id === id)
|
|
1224
|
+
if (index >= 0) labels.splice(index, 1)
|
|
1225
|
+
// and off every issue that had it, the way the server does
|
|
1226
|
+
for (const issue of state.issues) issue.labelIds = issue.labelIds.filter((l) => l !== id)
|
|
1227
|
+
return { ok: true as const }
|
|
1228
|
+
},
|
|
1229
|
+
},
|
|
1230
|
+
|
|
1231
|
+
components: {
|
|
1232
|
+
// The count is derived rather than stored: the server counts issues, and a mock that keeps a
|
|
1233
|
+
// number of its own drifts from the list it is meant to describe the moment anything moves.
|
|
1234
|
+
list: async ({ projectId }: Ws & { projectId: string }) =>
|
|
1235
|
+
clone(
|
|
1236
|
+
state.components
|
|
1237
|
+
.filter((c) => c.projectId === projectId)
|
|
1238
|
+
.map((c) => ({
|
|
1239
|
+
...c,
|
|
1240
|
+
issueCount: state.issues.filter((i) => i.componentIds.includes(c.id)).length,
|
|
1241
|
+
})),
|
|
1242
|
+
),
|
|
1243
|
+
create: async ({
|
|
1244
|
+
projectId,
|
|
1245
|
+
name,
|
|
1246
|
+
description,
|
|
1247
|
+
leadId,
|
|
1248
|
+
defaultAssignee,
|
|
1249
|
+
}: Ws & {
|
|
1250
|
+
projectId: string
|
|
1251
|
+
name: string
|
|
1252
|
+
description?: string | null
|
|
1253
|
+
leadId?: Component['leadId']
|
|
1254
|
+
defaultAssignee?: Component['defaultAssignee']
|
|
1255
|
+
}) => {
|
|
1256
|
+
const now = new Date().toISOString()
|
|
1257
|
+
const component: Component = {
|
|
1258
|
+
id: uid(2200 + state.components.length),
|
|
1259
|
+
workspaceId: WORKSPACE,
|
|
1260
|
+
projectId,
|
|
1261
|
+
name,
|
|
1262
|
+
description: description ?? null,
|
|
1263
|
+
leadId: leadId ?? null,
|
|
1264
|
+
defaultAssignee: defaultAssignee ?? 'none',
|
|
1265
|
+
issueCount: 0,
|
|
1266
|
+
createdAt: now,
|
|
1267
|
+
updatedAt: now,
|
|
1268
|
+
}
|
|
1269
|
+
state.components.push(component)
|
|
1270
|
+
return clone(component)
|
|
1271
|
+
},
|
|
1272
|
+
update: async ({ id, patch }: Ws & { id: string; patch: Partial<Component> }) => {
|
|
1273
|
+
const component = state.components.find((c) => c.id === id)
|
|
1274
|
+
if (!component) throw new Error(`[mock] unknown component ${id}`)
|
|
1275
|
+
Object.assign(component, patch, { updatedAt: new Date().toISOString() })
|
|
1276
|
+
return clone(component)
|
|
1277
|
+
},
|
|
1278
|
+
delete: async ({ id }: Ws & { id: string }) => {
|
|
1279
|
+
state.components = state.components.filter((c) => c.id !== id)
|
|
1280
|
+
return { ok: true as const }
|
|
1281
|
+
},
|
|
1282
|
+
},
|
|
1283
|
+
|
|
1284
|
+
versions: {
|
|
1285
|
+
list: async ({ projectId }: Ws & { projectId: string }) =>
|
|
1286
|
+
clone(state.versions.filter((v) => v.projectId === projectId)),
|
|
1287
|
+
create: async ({ projectId, name }: Ws & { projectId: string; name: string }) => {
|
|
1288
|
+
const now = new Date().toISOString()
|
|
1289
|
+
const version: Version = {
|
|
1290
|
+
id: uid(2300 + state.versions.length),
|
|
1291
|
+
workspaceId: WORKSPACE,
|
|
1292
|
+
projectId,
|
|
1293
|
+
name,
|
|
1294
|
+
description: null,
|
|
1295
|
+
status: 'unreleased',
|
|
1296
|
+
startDate: null,
|
|
1297
|
+
releaseDate: null,
|
|
1298
|
+
releasedAt: null,
|
|
1299
|
+
stats: { total: 0, done: 0 },
|
|
1300
|
+
order: state.versions.length,
|
|
1301
|
+
createdAt: now,
|
|
1302
|
+
updatedAt: now,
|
|
1303
|
+
}
|
|
1304
|
+
state.versions.push(version)
|
|
1305
|
+
return clone(version)
|
|
1306
|
+
},
|
|
1307
|
+
update: async ({ id, patch }: Ws & { id: string; patch: Partial<Version> }) => {
|
|
1308
|
+
const version = state.versions.find((v) => v.id === id)
|
|
1309
|
+
if (!version) throw new Error(`[mock] unknown version ${id}`)
|
|
1310
|
+
Object.assign(version, patch, { updatedAt: new Date().toISOString() })
|
|
1311
|
+
return clone(version)
|
|
1312
|
+
},
|
|
1313
|
+
delete: async ({ id }: Ws & { id: string }) => {
|
|
1314
|
+
state.versions = state.versions.filter((v) => v.id !== id)
|
|
1315
|
+
return { ok: true as const }
|
|
1316
|
+
},
|
|
1317
|
+
release: async ({ id, released }: Ws & { id: string; released: boolean }) => {
|
|
1318
|
+
const version = state.versions.find((v) => v.id === id)
|
|
1319
|
+
if (!version) throw new Error(`[mock] unknown version ${id}`)
|
|
1320
|
+
version.status = released ? 'released' : 'unreleased'
|
|
1321
|
+
version.releasedAt = released ? new Date().toISOString() : null
|
|
1322
|
+
return clone(version)
|
|
1323
|
+
},
|
|
1324
|
+
},
|
|
1325
|
+
fields: {
|
|
1326
|
+
list: async ({ includeArchived }: Ws & { includeArchived?: boolean }) =>
|
|
1327
|
+
clone(fields.filter((f) => includeArchived || !f.archivedAt)),
|
|
1328
|
+
archive: async ({ id, archived }: Ws & { id: string; archived?: boolean }) => {
|
|
1329
|
+
const field = fields.find((f) => f.id === id)
|
|
1330
|
+
if (!field) throw new Error(`[mock] unknown field ${id}`)
|
|
1331
|
+
field.archivedAt = archived === false ? null : new Date().toISOString()
|
|
1332
|
+
return clone(field)
|
|
1333
|
+
},
|
|
1334
|
+
create: async (input: Ws & { key: string; name: string; type: FieldDef['type'] }) => {
|
|
1335
|
+
if (fields.some((f) => f.key === input.key))
|
|
1336
|
+
// Same rule as the database: the key is where the value lives, so it is unique per
|
|
1337
|
+
// workspace whatever the project scope.
|
|
1338
|
+
throw new Error(`[mock] the key "${input.key}" is already used`)
|
|
1339
|
+
const now = new Date().toISOString()
|
|
1340
|
+
const field: FieldDef = {
|
|
1341
|
+
id: uid(1800 + fields.length),
|
|
1342
|
+
workspaceId: WORKSPACE,
|
|
1343
|
+
projectId: null,
|
|
1344
|
+
key: input.key,
|
|
1345
|
+
name: input.name,
|
|
1346
|
+
description: null,
|
|
1347
|
+
type: input.type,
|
|
1348
|
+
options: [],
|
|
1349
|
+
defaultValue: null,
|
|
1350
|
+
config: {},
|
|
1351
|
+
searchable: false,
|
|
1352
|
+
required: false,
|
|
1353
|
+
showInCards: false,
|
|
1354
|
+
order: fields.length,
|
|
1355
|
+
archivedAt: null,
|
|
1356
|
+
createdAt: now,
|
|
1357
|
+
updatedAt: now,
|
|
1358
|
+
...(input as Partial<FieldDef>),
|
|
1359
|
+
}
|
|
1360
|
+
fields.push(field)
|
|
1361
|
+
return clone(field)
|
|
1362
|
+
},
|
|
1363
|
+
update: async ({ id, patch }: Ws & { id: string; patch: Partial<FieldDef> }) => {
|
|
1364
|
+
const field = fields.find((f) => f.id === id)
|
|
1365
|
+
if (!field) throw new Error(`[mock] unknown field ${id}`)
|
|
1366
|
+
Object.assign(field, patch, { updatedAt: new Date().toISOString() })
|
|
1367
|
+
return clone(field)
|
|
1368
|
+
},
|
|
1369
|
+
delete: async ({ id }: Ws & { id: string }) => {
|
|
1370
|
+
const field = fields.find((f) => f.id === id)
|
|
1371
|
+
const index = fields.findIndex((f) => f.id === id)
|
|
1372
|
+
if (index >= 0) fields.splice(index, 1)
|
|
1373
|
+
// Deleting a field strips its value from every issue, the way the server does.
|
|
1374
|
+
if (field) for (const issue of state.issues) delete issue.custom[field.key]
|
|
1375
|
+
return { ok: true as const }
|
|
1376
|
+
},
|
|
1377
|
+
},
|
|
1378
|
+
workflows: {
|
|
1379
|
+
statuses: async (_input: Ws) => clone(statuses),
|
|
1380
|
+
list: async ({ includeArchived }: Ws & { includeArchived?: boolean }) =>
|
|
1381
|
+
clone(state.workflows.filter((w) => includeArchived || !w.archivedAt)),
|
|
1382
|
+
// The three the server ships. The definitions are thin here — the chooser is what this is
|
|
1383
|
+
// for, and the mock has no engine to run them through.
|
|
1384
|
+
templates: async () => [
|
|
1385
|
+
{
|
|
1386
|
+
id: 'software',
|
|
1387
|
+
name: 'Software',
|
|
1388
|
+
definition: clone((state.workflows[0] as Workflow).definition),
|
|
1389
|
+
},
|
|
1390
|
+
{
|
|
1391
|
+
id: 'kanban',
|
|
1392
|
+
name: 'Kanban',
|
|
1393
|
+
definition: clone((state.workflows[0] as Workflow).definition),
|
|
1394
|
+
},
|
|
1395
|
+
{
|
|
1396
|
+
id: 'simple',
|
|
1397
|
+
name: 'Simple',
|
|
1398
|
+
definition: clone((state.workflows[0] as Workflow).definition),
|
|
1399
|
+
},
|
|
1400
|
+
],
|
|
1401
|
+
validate: async ({ definition }: Ws & { definition: unknown }) => {
|
|
1402
|
+
const def = definition as Workflow['definition']
|
|
1403
|
+
const ids = new Set<string>()
|
|
1404
|
+
const problems: Array<{ path: string; message: string }> = []
|
|
1405
|
+
for (const [i, st] of def.statuses.entries()) {
|
|
1406
|
+
if (ids.has(st.id))
|
|
1407
|
+
problems.push({ path: `statuses.${i}.id`, message: `Duplicate status id "${st.id}"` })
|
|
1408
|
+
ids.add(st.id)
|
|
1409
|
+
}
|
|
1410
|
+
for (const [i, t] of def.transitions.entries()) {
|
|
1411
|
+
if (!ids.has(t.to))
|
|
1412
|
+
problems.push({ path: `transitions.${i}.to`, message: `Unknown target status "${t.to}"` })
|
|
1413
|
+
}
|
|
1414
|
+
return { ok: problems.length === 0, problems }
|
|
1415
|
+
},
|
|
1416
|
+
create: async ({ name, definition }: Ws & { name: string; definition: Workflow['definition'] }) => {
|
|
1417
|
+
const now = new Date().toISOString()
|
|
1418
|
+
const workflow: Workflow = {
|
|
1419
|
+
...(state.workflows[0] as Workflow),
|
|
1420
|
+
id: uid(2800 + state.workflows.length),
|
|
1421
|
+
name,
|
|
1422
|
+
description: null,
|
|
1423
|
+
isDefault: false,
|
|
1424
|
+
definition: clone(definition),
|
|
1425
|
+
archivedAt: null,
|
|
1426
|
+
createdAt: now,
|
|
1427
|
+
updatedAt: now,
|
|
1428
|
+
}
|
|
1429
|
+
state.workflows.push(workflow)
|
|
1430
|
+
return clone(workflow)
|
|
1431
|
+
},
|
|
1432
|
+
archive: async ({ id, archived }: Ws & { id: string; archived?: boolean }) => {
|
|
1433
|
+
const workflow = state.workflows.find((w) => w.id === id)
|
|
1434
|
+
if (!workflow) throw new Error(`[mock] unknown workflow ${id}`)
|
|
1435
|
+
if (workflow.isDefault && archived !== false)
|
|
1436
|
+
throw new Error('[mock] the default workflow cannot be archived')
|
|
1437
|
+
workflow.archivedAt = archived === false ? null : new Date().toISOString()
|
|
1438
|
+
return clone(workflow)
|
|
1439
|
+
},
|
|
1440
|
+
update: async ({ id, patch }: Ws & { id: string; patch: Partial<Workflow> }) => {
|
|
1441
|
+
const workflow = state.workflows.find((w) => w.id === id)
|
|
1442
|
+
if (!workflow) throw new Error(`[mock] unknown workflow ${id}`)
|
|
1443
|
+
Object.assign(workflow, patch, { updatedAt: new Date().toISOString() })
|
|
1444
|
+
// The board and every status label read from `statuses`, so a rename has to reach it.
|
|
1445
|
+
for (const status of workflow.definition.statuses) {
|
|
1446
|
+
const known = statuses.find((s) => s.id === status.id)
|
|
1447
|
+
if (known) known.name = status.name
|
|
1448
|
+
}
|
|
1449
|
+
return clone(workflow)
|
|
1450
|
+
},
|
|
1451
|
+
},
|
|
1452
|
+
milestones: {
|
|
1453
|
+
list: async ({ projectId }: Ws & { projectId?: string }) =>
|
|
1454
|
+
clone(state.milestones.filter((ms) => !projectId || ms.projectId === projectId)),
|
|
1455
|
+
create: async ({
|
|
1456
|
+
projectId,
|
|
1457
|
+
name,
|
|
1458
|
+
description,
|
|
1459
|
+
targetDate,
|
|
1460
|
+
}: Ws & {
|
|
1461
|
+
projectId: string
|
|
1462
|
+
name: string
|
|
1463
|
+
description?: string | null
|
|
1464
|
+
targetDate?: string | null
|
|
1465
|
+
}) => {
|
|
1466
|
+
const now = new Date().toISOString()
|
|
1467
|
+
const milestone: Milestone = {
|
|
1468
|
+
id: uid(2600 + state.milestones.length),
|
|
1469
|
+
workspaceId: WORKSPACE,
|
|
1470
|
+
projectId,
|
|
1471
|
+
name,
|
|
1472
|
+
description: description ?? null,
|
|
1473
|
+
targetDate: targetDate ?? null,
|
|
1474
|
+
status: 'open',
|
|
1475
|
+
stats: { total: 0, done: 0 },
|
|
1476
|
+
completedAt: null,
|
|
1477
|
+
createdAt: now,
|
|
1478
|
+
updatedAt: now,
|
|
1479
|
+
}
|
|
1480
|
+
state.milestones.push(milestone)
|
|
1481
|
+
return clone(milestone)
|
|
1482
|
+
},
|
|
1483
|
+
update: async ({ id, patch }: Ws & { id: string; patch: Partial<Milestone> }) => {
|
|
1484
|
+
const milestone = state.milestones.find((ms) => ms.id === id)
|
|
1485
|
+
if (!milestone) throw new Error(`[mock] unknown milestone ${id}`)
|
|
1486
|
+
Object.assign(milestone, patch, { updatedAt: new Date().toISOString() })
|
|
1487
|
+
if (patch.status)
|
|
1488
|
+
milestone.completedAt = patch.status === 'completed' ? new Date().toISOString() : null
|
|
1489
|
+
return clone(milestone)
|
|
1490
|
+
},
|
|
1491
|
+
delete: async ({ id }: Ws & { id: string }) => {
|
|
1492
|
+
state.milestones = state.milestones.filter((ms) => ms.id !== id)
|
|
1493
|
+
return { ok: true as const }
|
|
1494
|
+
},
|
|
1495
|
+
},
|
|
1496
|
+
cycles: {
|
|
1497
|
+
list: async ({ projectId }: Ws & { projectId?: string }) =>
|
|
1498
|
+
clone(state.cycles.filter((c) => !projectId || c.projectId === projectId)),
|
|
1499
|
+
get: async ({ id }: Ws & { id: string }) => {
|
|
1500
|
+
const cycle = state.cycles.find((c) => c.id === id)
|
|
1501
|
+
if (!cycle) throw new Error(`[mock] unknown cycle ${id}`)
|
|
1502
|
+
return clone(cycle)
|
|
1503
|
+
},
|
|
1504
|
+
create: async ({
|
|
1505
|
+
projectId,
|
|
1506
|
+
name,
|
|
1507
|
+
goal,
|
|
1508
|
+
startAt,
|
|
1509
|
+
endAt,
|
|
1510
|
+
}: Ws & {
|
|
1511
|
+
projectId: string
|
|
1512
|
+
name?: string
|
|
1513
|
+
goal?: string | null
|
|
1514
|
+
startAt: string
|
|
1515
|
+
endAt: string
|
|
1516
|
+
}) => {
|
|
1517
|
+
const now = new Date().toISOString()
|
|
1518
|
+
const number =
|
|
1519
|
+
Math.max(0, ...state.cycles.filter((c) => c.projectId === projectId).map((c) => c.number)) + 1
|
|
1520
|
+
const cycle: Cycle = {
|
|
1521
|
+
id: uid(2700 + state.cycles.length),
|
|
1522
|
+
workspaceId: WORKSPACE,
|
|
1523
|
+
projectId,
|
|
1524
|
+
number,
|
|
1525
|
+
name: name || `Cycle ${number}`,
|
|
1526
|
+
goal: goal ?? null,
|
|
1527
|
+
startAt,
|
|
1528
|
+
endAt,
|
|
1529
|
+
status: 'upcoming',
|
|
1530
|
+
startedAt: null,
|
|
1531
|
+
completedAt: null,
|
|
1532
|
+
carryOverCount: 0,
|
|
1533
|
+
stats: { total: 0, done: 0, estimateTotal: 0, estimateDone: 0 },
|
|
1534
|
+
createdAt: now,
|
|
1535
|
+
updatedAt: now,
|
|
1536
|
+
}
|
|
1537
|
+
state.cycles.push(cycle)
|
|
1538
|
+
return clone(cycle)
|
|
1539
|
+
},
|
|
1540
|
+
update: async ({ id, patch }: Ws & { id: string; patch: Partial<Cycle> }) => {
|
|
1541
|
+
const cycle = state.cycles.find((c) => c.id === id)
|
|
1542
|
+
if (!cycle) throw new Error(`[mock] unknown cycle ${id}`)
|
|
1543
|
+
Object.assign(cycle, patch, { updatedAt: new Date().toISOString() })
|
|
1544
|
+
return clone(cycle)
|
|
1545
|
+
},
|
|
1546
|
+
start: async ({ id }: Ws & { id: string }) => {
|
|
1547
|
+
const cycle = state.cycles.find((c) => c.id === id)
|
|
1548
|
+
if (!cycle) throw new Error(`[mock] unknown cycle ${id}`)
|
|
1549
|
+
// One at a time, like the server: starting a second while one runs is a conflict, not a swap.
|
|
1550
|
+
if (state.cycles.some((c) => c.projectId === cycle.projectId && c.status === 'active'))
|
|
1551
|
+
throw new Error('[mock] another cycle is already active')
|
|
1552
|
+
cycle.status = 'active'
|
|
1553
|
+
cycle.startedAt = new Date().toISOString()
|
|
1554
|
+
return clone(cycle)
|
|
1555
|
+
},
|
|
1556
|
+
complete: async ({ id, rollToCycleId }: Ws & { id: string; rollToCycleId?: string | null }) => {
|
|
1557
|
+
const cycle = state.cycles.find((c) => c.id === id)
|
|
1558
|
+
if (!cycle) throw new Error(`[mock] unknown cycle ${id}`)
|
|
1559
|
+
// Omitted means "choose for me"; null means the backlog. The server draws the same line.
|
|
1560
|
+
const target =
|
|
1561
|
+
rollToCycleId === undefined
|
|
1562
|
+
? (state.cycles.find((c) => c.projectId === cycle.projectId && c.status === 'upcoming')?.id ??
|
|
1563
|
+
null)
|
|
1564
|
+
: rollToCycleId
|
|
1565
|
+
const unfinished = state.issues.filter(
|
|
1566
|
+
(i) => i.cycleId === id && i.statusCategory !== 'done' && i.statusCategory !== 'cancelled',
|
|
1567
|
+
)
|
|
1568
|
+
for (const issue of unfinished) issue.cycleId = target
|
|
1569
|
+
if (target && unfinished.length) {
|
|
1570
|
+
const next = state.cycles.find((c) => c.id === target)
|
|
1571
|
+
if (next) next.carryOverCount += unfinished.length
|
|
1572
|
+
}
|
|
1573
|
+
cycle.status = 'completed'
|
|
1574
|
+
cycle.completedAt = new Date().toISOString()
|
|
1575
|
+
return clone(cycle)
|
|
1576
|
+
},
|
|
1577
|
+
delete: async ({ id }: Ws & { id: string }) => {
|
|
1578
|
+
state.cycles = state.cycles.filter((c) => c.id !== id)
|
|
1579
|
+
return { ok: true as const }
|
|
1580
|
+
},
|
|
1581
|
+
},
|
|
1582
|
+
worklogs: {
|
|
1583
|
+
list: async ({ issueId }: Ws & { issueId: string }) =>
|
|
1584
|
+
clone(state.worklogs.filter((w) => w.issueId === issueId)),
|
|
1585
|
+
create: async ({
|
|
1586
|
+
issueId,
|
|
1587
|
+
durationSec,
|
|
1588
|
+
note,
|
|
1589
|
+
}: Ws & { issueId: string; durationSec: number; note?: string | null }) => {
|
|
1590
|
+
const issue = find(issueId)
|
|
1591
|
+
const worklog: Worklog = {
|
|
1592
|
+
id: uid(2400 + state.worklogs.length),
|
|
1593
|
+
workspaceId: WORKSPACE,
|
|
1594
|
+
projectId: issue.projectId,
|
|
1595
|
+
issueId,
|
|
1596
|
+
userId: ME,
|
|
1597
|
+
startedAt: new Date().toISOString(),
|
|
1598
|
+
durationSec,
|
|
1599
|
+
note: note ?? null,
|
|
1600
|
+
billable: true,
|
|
1601
|
+
createdAt: new Date().toISOString(),
|
|
1602
|
+
updatedAt: new Date().toISOString(),
|
|
1603
|
+
}
|
|
1604
|
+
state.worklogs.push(worklog)
|
|
1605
|
+
issue.timeSpentSec += durationSec
|
|
1606
|
+
touch(issue)
|
|
1607
|
+
return { worklog: clone(worklog), issue: clone(issue) }
|
|
1608
|
+
},
|
|
1609
|
+
update: async ({ id, patch }: Ws & { id: string; patch: Record<string, unknown> }) => {
|
|
1610
|
+
const worklog = state.worklogs.find((w) => w.id === id)
|
|
1611
|
+
if (!worklog) throw new Error(`[mock] unknown worklog ${id}`)
|
|
1612
|
+
Object.assign(worklog, patch, { updatedAt: new Date().toISOString() })
|
|
1613
|
+
// The issue's total is the sum of its entries, so correcting one has to reach it.
|
|
1614
|
+
const issue = state.issues.find((i) => i.id === worklog.issueId)
|
|
1615
|
+
if (issue)
|
|
1616
|
+
issue.timeSpentSec = state.worklogs
|
|
1617
|
+
.filter((w) => w.issueId === worklog.issueId)
|
|
1618
|
+
.reduce((sum, w) => sum + w.durationSec, 0)
|
|
1619
|
+
return clone(worklog)
|
|
1620
|
+
},
|
|
1621
|
+
delete: async ({ id }: Ws & { id: string }) => {
|
|
1622
|
+
const gone = state.worklogs.find((w) => w.id === id)
|
|
1623
|
+
state.worklogs = state.worklogs.filter((w) => w.id !== id)
|
|
1624
|
+
if (gone) {
|
|
1625
|
+
const issue = state.issues.find((i) => i.id === gone.issueId)
|
|
1626
|
+
if (issue) issue.timeSpentSec = Math.max(0, issue.timeSpentSec - gone.durationSec)
|
|
1627
|
+
}
|
|
1628
|
+
return { ok: true as const }
|
|
1629
|
+
},
|
|
1630
|
+
timers: {
|
|
1631
|
+
current: async (_input: Ws) => clone(state.timer),
|
|
1632
|
+
start: async ({ issueId }: Ws & { issueId: string }) => {
|
|
1633
|
+
// A timer belongs to a person: starting one replaces whatever they had running.
|
|
1634
|
+
const timer: Timer = {
|
|
1635
|
+
id: uid(2500),
|
|
1636
|
+
workspaceId: WORKSPACE,
|
|
1637
|
+
issueId,
|
|
1638
|
+
userId: ME,
|
|
1639
|
+
startedAt: new Date().toISOString(),
|
|
1640
|
+
note: null,
|
|
1641
|
+
}
|
|
1642
|
+
state.timer = timer
|
|
1643
|
+
return clone(timer)
|
|
1644
|
+
},
|
|
1645
|
+
stop: async ({ discard }: Ws & { discard?: boolean }) => {
|
|
1646
|
+
const timer = state.timer
|
|
1647
|
+
state.timer = null
|
|
1648
|
+
if (!timer || discard) return { worklog: null }
|
|
1649
|
+
const issue = find(timer.issueId)
|
|
1650
|
+
const durationSec = Math.max(60, Math.round((Date.now() - Date.parse(timer.startedAt)) / 1000))
|
|
1651
|
+
const worklog: Worklog = {
|
|
1652
|
+
id: uid(2400 + state.worklogs.length),
|
|
1653
|
+
workspaceId: WORKSPACE,
|
|
1654
|
+
projectId: issue.projectId,
|
|
1655
|
+
issueId: timer.issueId,
|
|
1656
|
+
userId: ME,
|
|
1657
|
+
startedAt: timer.startedAt,
|
|
1658
|
+
durationSec,
|
|
1659
|
+
note: timer.note,
|
|
1660
|
+
billable: true,
|
|
1661
|
+
createdAt: new Date().toISOString(),
|
|
1662
|
+
updatedAt: new Date().toISOString(),
|
|
1663
|
+
}
|
|
1664
|
+
state.worklogs.push(worklog)
|
|
1665
|
+
issue.timeSpentSec += durationSec
|
|
1666
|
+
touch(issue)
|
|
1667
|
+
return { worklog: clone(worklog) }
|
|
1668
|
+
},
|
|
1669
|
+
},
|
|
1670
|
+
},
|
|
1671
|
+
|
|
1672
|
+
intake: {
|
|
1673
|
+
// A single well-known token, so the demo form has a link that works.
|
|
1674
|
+
form: async ({ token }: { token: string }) => {
|
|
1675
|
+
if (token !== 'demo-intake-token') throw new Error('[mock] unknown intake token')
|
|
1676
|
+
const project = projects[0] as Project
|
|
1677
|
+
return clone({
|
|
1678
|
+
projectId: project.id,
|
|
1679
|
+
projectName: project.name,
|
|
1680
|
+
token,
|
|
1681
|
+
title: `Contact ${project.name}`,
|
|
1682
|
+
description: 'Tell us what went wrong and we will pick it up.',
|
|
1683
|
+
fields: [
|
|
1684
|
+
{ key: 'name', label: 'Your name', type: 'text' as const, required: false },
|
|
1685
|
+
{ key: 'email', label: 'Email', type: 'email' as const, required: true },
|
|
1686
|
+
{ key: 'title', label: 'Summary', type: 'text' as const, required: true },
|
|
1687
|
+
{ key: 'description', label: 'Details', type: 'textarea' as const, required: false },
|
|
1688
|
+
{
|
|
1689
|
+
key: 'cf.severity',
|
|
1690
|
+
label: 'Severity',
|
|
1691
|
+
description: 'How badly this hurts in production',
|
|
1692
|
+
type: 'select' as const,
|
|
1693
|
+
required: false,
|
|
1694
|
+
options: fields
|
|
1695
|
+
.find((f) => f.key === 'severity')!
|
|
1696
|
+
.options.map((o) => ({ value: o.id, label: o.label })),
|
|
1697
|
+
},
|
|
1698
|
+
],
|
|
1699
|
+
allowAttachments: false,
|
|
1700
|
+
})
|
|
1701
|
+
},
|
|
1702
|
+
submit: async (input: { token: string; title: string; website?: string }) => {
|
|
1703
|
+
if (input.website) throw new Error('[mock] rejected')
|
|
1704
|
+
if (input.token !== 'demo-intake-token') throw new Error('[mock] unknown intake token')
|
|
1705
|
+
const project = projects[0] as Project
|
|
1706
|
+
const number = (state.counters.get(project.id) ?? 0) + 1
|
|
1707
|
+
state.counters.set(project.id, number)
|
|
1708
|
+
return { ok: true as const, issueKey: `${project.key}-${number}` }
|
|
1709
|
+
},
|
|
1710
|
+
},
|
|
1711
|
+
|
|
1712
|
+
imports: {
|
|
1713
|
+
list: async ({ projectId }: Ws & { projectId: string }) =>
|
|
1714
|
+
clone(state.imports.filter((j) => j.projectId === projectId)),
|
|
1715
|
+
get: async ({ id }: Ws & { id: string }) => {
|
|
1716
|
+
const job = state.imports.find((j) => j.id === id)
|
|
1717
|
+
if (!job) throw new Error(`[mock] unknown import ${id}`)
|
|
1718
|
+
// Advance a little on each poll, so the progress bar is something a person can watch
|
|
1719
|
+
// rather than a number that appears finished before it started.
|
|
1720
|
+
if (job.status === 'running') {
|
|
1721
|
+
job.progress.processed = Math.min(job.progress.total, job.progress.processed + 4)
|
|
1722
|
+
job.progress.created = Math.max(0, job.progress.processed - job.progress.failed)
|
|
1723
|
+
if (job.progress.processed >= job.progress.total) {
|
|
1724
|
+
job.status = 'completed'
|
|
1725
|
+
job.finishedAt = new Date().toISOString()
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
return clone(job)
|
|
1729
|
+
},
|
|
1730
|
+
start: async ({ projectId, source, fileId, mapping }: Ws & Record<string, never>) => {
|
|
1731
|
+
const now = new Date().toISOString()
|
|
1732
|
+
const job: ImportJob = {
|
|
1733
|
+
id: uid(2800 + state.imports.length),
|
|
1734
|
+
workspaceId: WORKSPACE,
|
|
1735
|
+
projectId: projectId as unknown as string,
|
|
1736
|
+
source: (source ?? 'csv') as ImportJob['source'],
|
|
1737
|
+
fileId: fileId as unknown as string,
|
|
1738
|
+
mapping: (mapping ?? {}) as Record<string, unknown>,
|
|
1739
|
+
status: 'running',
|
|
1740
|
+
// A row that fails is part of what an import looks like; a demo where nothing ever
|
|
1741
|
+
// fails teaches nobody what the errors list is for.
|
|
1742
|
+
progress: { total: 12, processed: 0, created: 0, skipped: 0, failed: 1 },
|
|
1743
|
+
errors: [{ row: 3, message: 'Row 3 has no title' }],
|
|
1744
|
+
createdBy: ME,
|
|
1745
|
+
startedAt: now,
|
|
1746
|
+
finishedAt: null,
|
|
1747
|
+
createdAt: now,
|
|
1748
|
+
updatedAt: now,
|
|
1749
|
+
} as ImportJob
|
|
1750
|
+
state.imports.push(job)
|
|
1751
|
+
return clone(job)
|
|
1752
|
+
},
|
|
1753
|
+
cancel: async ({ id }: Ws & { id: string }) => {
|
|
1754
|
+
const job = state.imports.find((j) => j.id === id)
|
|
1755
|
+
if (!job) throw new Error(`[mock] unknown import ${id}`)
|
|
1756
|
+
job.status = 'cancelled'
|
|
1757
|
+
job.finishedAt = new Date().toISOString()
|
|
1758
|
+
return clone(job)
|
|
1759
|
+
},
|
|
1760
|
+
},
|
|
1761
|
+
|
|
1762
|
+
reports: {
|
|
1763
|
+
burndown: async ({ cycleId }: Ws & { cycleId: string }) => {
|
|
1764
|
+
const cycle = state.cycles.find((c) => c.id === cycleId) ?? state.cycles[0]
|
|
1765
|
+
if (!cycle) throw new Error('[mock] no cycle to burn down')
|
|
1766
|
+
// A straight ideal line and a plausible actual one: enough for the chart to be exercised
|
|
1767
|
+
// without pretending the mock recomputes history.
|
|
1768
|
+
const days = 14
|
|
1769
|
+
const scope = 40
|
|
1770
|
+
const points = Array.from({ length: days }, (_, i) => {
|
|
1771
|
+
const done = Math.min(scope, Math.round((i / (days - 1)) * scope * 0.85))
|
|
1772
|
+
return {
|
|
1773
|
+
// `iso` counts backwards from now, so day 0 is the furthest back and the axis reads
|
|
1774
|
+
// left to right the way a person reads a date.
|
|
1775
|
+
date: day(-(days - 1 - i)),
|
|
1776
|
+
remaining: scope - done,
|
|
1777
|
+
ideal: Math.round(scope - (i / (days - 1)) * scope),
|
|
1778
|
+
completed: done,
|
|
1779
|
+
scope,
|
|
1780
|
+
scopeChange: 0,
|
|
1781
|
+
}
|
|
1782
|
+
})
|
|
1783
|
+
return clone({ cycle, unit: 'points' as const, points })
|
|
1784
|
+
},
|
|
1785
|
+
velocity: async (_input: Ws & { projectId: string }) => ({
|
|
1786
|
+
unit: 'points' as const,
|
|
1787
|
+
cycles: state.cycles.slice(0, 4).map((c, i) => ({
|
|
1788
|
+
cycle: {
|
|
1789
|
+
id: c.id,
|
|
1790
|
+
number: c.number,
|
|
1791
|
+
name: c.name,
|
|
1792
|
+
startAt: c.startAt,
|
|
1793
|
+
endAt: c.endAt,
|
|
1794
|
+
status: c.status,
|
|
1795
|
+
},
|
|
1796
|
+
committed: 30 + i * 4,
|
|
1797
|
+
completed: 26 + i * 3,
|
|
1798
|
+
committedCount: 12 + i,
|
|
1799
|
+
completedCount: 10 + i,
|
|
1800
|
+
})),
|
|
1801
|
+
average: 29,
|
|
1802
|
+
}),
|
|
1803
|
+
cfd: async ({ from, to }: Ws & { from: string; to: string }) => {
|
|
1804
|
+
const start = Date.parse(from)
|
|
1805
|
+
const days = Math.max(1, Math.round((Date.parse(to) - start) / DAY) + 1)
|
|
1806
|
+
// Work flows: the backlog drains, in-progress bulges in the middle, done climbs. A flat
|
|
1807
|
+
// demo would make the chart look broken rather than calm.
|
|
1808
|
+
const shape = (i: number) => {
|
|
1809
|
+
const t = days > 1 ? i / (days - 1) : 1
|
|
1810
|
+
return {
|
|
1811
|
+
backlog: Math.round(20 - 12 * t),
|
|
1812
|
+
todo: Math.round(6 + 3 * Math.sin(t * Math.PI)),
|
|
1813
|
+
in_progress: Math.round(3 + 6 * Math.sin(t * Math.PI)),
|
|
1814
|
+
in_review: Math.round(1 + 3 * Math.sin(t * Math.PI * 0.8)),
|
|
1815
|
+
done: Math.round(2 + 22 * t),
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
return {
|
|
1819
|
+
statuses: statuses
|
|
1820
|
+
.filter((st) => ['backlog', 'todo', 'in_progress', 'in_review', 'done'].includes(st.id))
|
|
1821
|
+
.map((st) => ({ id: st.id, name: st.name, category: st.category, color: st.color })),
|
|
1822
|
+
points: Array.from({ length: days }, (_, i) => ({
|
|
1823
|
+
date: new Date(start + i * DAY).toISOString().slice(0, 10),
|
|
1824
|
+
counts: shape(i) as Record<string, number>,
|
|
1825
|
+
})),
|
|
1826
|
+
}
|
|
1827
|
+
},
|
|
1828
|
+
createdVsResolved: async ({ from, to }: Ws & { from: string; to: string }) => {
|
|
1829
|
+
const start = Date.parse(from)
|
|
1830
|
+
const days = Math.max(1, Math.round((Date.parse(to) - start) / DAY) + 1)
|
|
1831
|
+
let open = 18
|
|
1832
|
+
const points = Array.from({ length: days }, (_, i) => {
|
|
1833
|
+
const created = (i % 5) + 1
|
|
1834
|
+
const resolved = (i % 4) + 1
|
|
1835
|
+
open = Math.max(0, open + created - resolved)
|
|
1836
|
+
return {
|
|
1837
|
+
date: new Date(start + i * DAY).toISOString().slice(0, 10),
|
|
1838
|
+
created,
|
|
1839
|
+
resolved,
|
|
1840
|
+
openTotal: open,
|
|
1841
|
+
}
|
|
1842
|
+
})
|
|
1843
|
+
return { points }
|
|
1844
|
+
},
|
|
1845
|
+
time: async ({ from, to }: Ws & { from: string; to: string }) => {
|
|
1846
|
+
const withTime = state.issues.filter((i) => i.timeSpentSec > 0).slice(0, 10)
|
|
1847
|
+
const totalSec = withTime.reduce((sum, i) => sum + i.timeSpentSec, 0)
|
|
1848
|
+
return clone({
|
|
1849
|
+
from,
|
|
1850
|
+
to,
|
|
1851
|
+
totalSec,
|
|
1852
|
+
billableSec: Math.round(totalSec * 0.8),
|
|
1853
|
+
rows: [],
|
|
1854
|
+
byUser: totalSec
|
|
1855
|
+
? [{ userId: ME, durationSec: totalSec, billableSec: Math.round(totalSec * 0.8) }]
|
|
1856
|
+
: [],
|
|
1857
|
+
byIssue: withTime.map((issue) => ({
|
|
1858
|
+
issueId: issue.id,
|
|
1859
|
+
issueKey: issue.key,
|
|
1860
|
+
title: issue.title,
|
|
1861
|
+
durationSec: issue.timeSpentSec,
|
|
1862
|
+
originalEstimateSec: issue.estimate ? issue.estimate * 3600 : null,
|
|
1863
|
+
remainingSec: null,
|
|
1864
|
+
})),
|
|
1865
|
+
})
|
|
1866
|
+
},
|
|
1867
|
+
},
|
|
1868
|
+
|
|
1869
|
+
triage: {
|
|
1870
|
+
accept: async ({ issueId }: Ws & { issueId: string }) => {
|
|
1871
|
+
const issue = find(issueId)
|
|
1872
|
+
// Out of triage into the workflow's first real status, the way the server does it.
|
|
1873
|
+
const target = statuses.find((s) => s.category !== 'triage')
|
|
1874
|
+
if (target) {
|
|
1875
|
+
issue.statusId = target.id
|
|
1876
|
+
issue.statusCategory = target.category
|
|
1877
|
+
}
|
|
1878
|
+
issue.triage = false
|
|
1879
|
+
issue.snoozedUntil = null
|
|
1880
|
+
touch(issue)
|
|
1881
|
+
return clone(issue)
|
|
1882
|
+
},
|
|
1883
|
+
decline: async ({ issueId }: Ws & { issueId: string }) => {
|
|
1884
|
+
const issue = find(issueId)
|
|
1885
|
+
const cancelled = statuses.find((s) => s.category === 'cancelled')
|
|
1886
|
+
if (cancelled) {
|
|
1887
|
+
issue.statusId = cancelled.id
|
|
1888
|
+
issue.statusCategory = cancelled.category
|
|
1889
|
+
}
|
|
1890
|
+
issue.triage = false
|
|
1891
|
+
issue.cancelledAt = new Date().toISOString()
|
|
1892
|
+
touch(issue)
|
|
1893
|
+
return clone(issue)
|
|
1894
|
+
},
|
|
1895
|
+
snooze: async ({ issueId, until }: Ws & { issueId: string; until: string }) => {
|
|
1896
|
+
const issue = find(issueId)
|
|
1897
|
+
issue.snoozedUntil = until
|
|
1898
|
+
touch(issue)
|
|
1899
|
+
return clone(issue)
|
|
1900
|
+
},
|
|
1901
|
+
},
|
|
1902
|
+
|
|
1903
|
+
views: {
|
|
1904
|
+
list: async (_input: Ws): Promise<View[]> => clone(state.views),
|
|
1905
|
+
get: async ({ id }: Ws & { id: string }) => {
|
|
1906
|
+
const view = state.views.find((v) => v.id === id)
|
|
1907
|
+
if (!view) throw new Error(`[mock] unknown view ${id}`)
|
|
1908
|
+
return clone(view)
|
|
1909
|
+
},
|
|
1910
|
+
create: async (input: Ws & Partial<View> & { name: string }) => {
|
|
1911
|
+
const now = new Date().toISOString()
|
|
1912
|
+
const view: View = {
|
|
1913
|
+
id: uid(2000 + state.views.length),
|
|
1914
|
+
workspaceId: WORKSPACE,
|
|
1915
|
+
projectId: null,
|
|
1916
|
+
name: input.name,
|
|
1917
|
+
description: null,
|
|
1918
|
+
icon: null,
|
|
1919
|
+
kql: input.kql ?? '',
|
|
1920
|
+
layout: input.layout ?? 'list',
|
|
1921
|
+
display: { groupBy: 'status', ...(input.display ?? {}) } as View['display'],
|
|
1922
|
+
filters: {} as View['filters'],
|
|
1923
|
+
visibility: input.visibility ?? 'private',
|
|
1924
|
+
ownerId: ME,
|
|
1925
|
+
pinned: false,
|
|
1926
|
+
builtin: false,
|
|
1927
|
+
order: state.views.length,
|
|
1928
|
+
createdAt: now,
|
|
1929
|
+
updatedAt: now,
|
|
1930
|
+
}
|
|
1931
|
+
state.views.push(view)
|
|
1932
|
+
return clone(view)
|
|
1933
|
+
},
|
|
1934
|
+
update: async ({ id, patch }: Ws & { id: string; patch: Partial<View> }) => {
|
|
1935
|
+
const view = state.views.find((v) => v.id === id)
|
|
1936
|
+
if (!view) throw new Error(`[mock] unknown view ${id}`)
|
|
1937
|
+
Object.assign(view, patch, { updatedAt: new Date().toISOString() })
|
|
1938
|
+
return clone(view)
|
|
1939
|
+
},
|
|
1940
|
+
delete: async ({ id }: Ws & { id: string }) => {
|
|
1941
|
+
state.views = state.views.filter((v) => v.id !== id)
|
|
1942
|
+
return { ok: true as const }
|
|
1943
|
+
},
|
|
1944
|
+
pin: async ({ id, pinned }: Ws & { id: string; pinned: boolean }) => {
|
|
1945
|
+
const view = state.views.find((v) => v.id === id)
|
|
1946
|
+
if (!view) throw new Error(`[mock] unknown view ${id}`)
|
|
1947
|
+
view.pinned = pinned
|
|
1948
|
+
return clone(view)
|
|
1949
|
+
},
|
|
1950
|
+
},
|
|
1951
|
+
|
|
1952
|
+
kql: {
|
|
1953
|
+
fields: async (_input: Ws) =>
|
|
1954
|
+
[
|
|
1955
|
+
...SYSTEM_FIELD_INFO,
|
|
1956
|
+
...fields.map((f) => ({
|
|
1957
|
+
name: `cf.${f.key}`,
|
|
1958
|
+
type: f.type === 'number' ? ('number' as const) : ('enum' as const),
|
|
1959
|
+
label: f.name,
|
|
1960
|
+
operators: ['=', '!=', 'in', 'not-in', 'is-empty', 'is-not-empty'],
|
|
1961
|
+
values: f.options.map((o) => ({ value: o.label, label: o.label })),
|
|
1962
|
+
custom: true,
|
|
1963
|
+
sortable: true,
|
|
1964
|
+
})),
|
|
1965
|
+
] as never,
|
|
1966
|
+
parse: async ({ kql }: Ws & { kql: string }) => {
|
|
1967
|
+
const parsed = parseKql(kql)
|
|
1968
|
+
return {
|
|
1969
|
+
ok: parsed.ok,
|
|
1970
|
+
ast: parsed.ast as unknown,
|
|
1971
|
+
errors: parsed.errors,
|
|
1972
|
+
normalized: parsed.normalized,
|
|
1973
|
+
suggestions: parsed.suggestions,
|
|
1974
|
+
}
|
|
1975
|
+
},
|
|
1976
|
+
},
|
|
1977
|
+
|
|
1978
|
+
issues: {
|
|
1979
|
+
query: async (
|
|
1980
|
+
input: Ws & {
|
|
1981
|
+
kql?: string
|
|
1982
|
+
projectIds?: string[]
|
|
1983
|
+
orderBy?: Array<{ field: string; dir: 'asc' | 'desc' }>
|
|
1984
|
+
groupBy?: GroupBy
|
|
1985
|
+
limit?: number
|
|
1986
|
+
includeArchived?: boolean
|
|
1987
|
+
},
|
|
1988
|
+
) => {
|
|
1989
|
+
const parsed = parseKql(input.kql ?? '')
|
|
1990
|
+
const where = parsed.ok ? (parsed.ast?.where ?? null) : null
|
|
1991
|
+
let items = state.issues
|
|
1992
|
+
.filter((i) => input.includeArchived || i.archivedAt === null)
|
|
1993
|
+
.filter((i) => !input.projectIds?.length || input.projectIds.includes(i.projectId))
|
|
1994
|
+
.filter((i) => matches(i, where))
|
|
1995
|
+
|
|
1996
|
+
const order = input.orderBy?.length ? input.orderBy : (parsed.ast?.orderBy ?? [])
|
|
1997
|
+
if (order.length) {
|
|
1998
|
+
items = [...items].sort((a, b) => {
|
|
1999
|
+
for (const o of order) {
|
|
2000
|
+
const av = sortKey(a, o.field)
|
|
2001
|
+
const bv = sortKey(b, o.field)
|
|
2002
|
+
const d = av < bv ? -1 : av > bv ? 1 : 0
|
|
2003
|
+
if (d !== 0) return o.dir === 'desc' ? -d : d
|
|
2004
|
+
}
|
|
2005
|
+
return a.rank < b.rank ? -1 : 1
|
|
2006
|
+
})
|
|
2007
|
+
} else {
|
|
2008
|
+
items = [...items].sort((a, b) => (a.rank < b.rank ? -1 : 1))
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
return {
|
|
2012
|
+
items: clone(items.slice(0, input.limit ?? 100)),
|
|
2013
|
+
nextCursor: null,
|
|
2014
|
+
total: items.length,
|
|
2015
|
+
fields: [],
|
|
2016
|
+
}
|
|
2017
|
+
},
|
|
2018
|
+
|
|
2019
|
+
get: async ({ issueId }: Ws & { issueId: string }) => clone(find(issueId)),
|
|
2020
|
+
getByKey: async ({ key }: Ws & { key: string }) => {
|
|
2021
|
+
const issue = state.issues.find((i) => i.key.toLowerCase() === key.toLowerCase())
|
|
2022
|
+
if (!issue) throw new Error(`[mock] unknown issue ${key}`)
|
|
2023
|
+
return clone(issue)
|
|
2024
|
+
},
|
|
2025
|
+
getMany: async ({ ids }: Ws & { ids: string[] }) =>
|
|
2026
|
+
clone(state.issues.filter((i) => ids.includes(i.id))),
|
|
2027
|
+
|
|
2028
|
+
create: async (
|
|
2029
|
+
input: Ws & {
|
|
2030
|
+
projectId: string
|
|
2031
|
+
title: string
|
|
2032
|
+
typeId?: string
|
|
2033
|
+
description?: Issue['description']
|
|
2034
|
+
priority?: Priority
|
|
2035
|
+
assigneeIds?: Uid[]
|
|
2036
|
+
labelIds?: string[]
|
|
2037
|
+
estimate?: number | null
|
|
2038
|
+
dueDate?: string | null
|
|
2039
|
+
cycleId?: string | null
|
|
2040
|
+
statusId?: string
|
|
2041
|
+
},
|
|
2042
|
+
) => {
|
|
2043
|
+
const proj = projects.find((p) => p.id === input.projectId) ?? (projects[0] as Project)
|
|
2044
|
+
const number = (state.counters.get(proj.id) ?? 0) + 1
|
|
2045
|
+
state.counters.set(proj.id, number)
|
|
2046
|
+
const status = statusById.get(input.statusId ?? 'todo') ?? (statuses[2] as StatusInfo)
|
|
2047
|
+
const text = input.description?.content?.length ? plainText(input.description) : ''
|
|
2048
|
+
const issue: Issue = {
|
|
2049
|
+
...(state.issues[0] as Issue),
|
|
2050
|
+
id: uid(1000 + state.issues.length),
|
|
2051
|
+
projectId: proj.id,
|
|
2052
|
+
key: `${proj.key}-${number}`,
|
|
2053
|
+
number,
|
|
2054
|
+
typeId: input.typeId ?? (types[0] as WorkItemType).id,
|
|
2055
|
+
title: input.title,
|
|
2056
|
+
description: input.description ?? null,
|
|
2057
|
+
descriptionText: text,
|
|
2058
|
+
statusId: status.id,
|
|
2059
|
+
statusCategory: status.category,
|
|
2060
|
+
priority: input.priority ?? 'none',
|
|
2061
|
+
assigneeIds: input.assigneeIds ?? [],
|
|
2062
|
+
reporterId: ME,
|
|
2063
|
+
creatorId: ME,
|
|
2064
|
+
labelIds: input.labelIds ?? [],
|
|
2065
|
+
cycleId: input.cycleId ?? null,
|
|
2066
|
+
milestoneId: null,
|
|
2067
|
+
rank: rankSequence(1)[0] as string,
|
|
2068
|
+
estimate: input.estimate ?? null,
|
|
2069
|
+
dueDate: input.dueDate ?? null,
|
|
2070
|
+
completedAt: null,
|
|
2071
|
+
cancelledAt: null,
|
|
2072
|
+
resolution: null,
|
|
2073
|
+
custom: {},
|
|
2074
|
+
watcherIds: [ME],
|
|
2075
|
+
subscriberCount: 1,
|
|
2076
|
+
commentCount: 0,
|
|
2077
|
+
attachmentCount: 0,
|
|
2078
|
+
relationSummary: emptyRelations(),
|
|
2079
|
+
timeSpentSec: 0,
|
|
2080
|
+
triage: status.category === 'triage',
|
|
2081
|
+
source: 'app',
|
|
2082
|
+
archivedAt: null,
|
|
2083
|
+
createdAt: new Date().toISOString(),
|
|
2084
|
+
updatedAt: new Date().toISOString(),
|
|
2085
|
+
lastActivityAt: new Date().toISOString(),
|
|
2086
|
+
}
|
|
2087
|
+
// new work lands at the top of its column, which is where people look for it
|
|
2088
|
+
const first = state.issues[0]
|
|
2089
|
+
if (first) issue.rank = rankBetweenSafe(null, first.rank)
|
|
2090
|
+
state.issues.unshift(issue)
|
|
2091
|
+
return clone(issue)
|
|
2092
|
+
},
|
|
2093
|
+
|
|
2094
|
+
update: async ({ issueId, patch }: Ws & { issueId: string; patch: Record<string, unknown> }) => {
|
|
2095
|
+
const issue = find(issueId)
|
|
2096
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
2097
|
+
if (value === undefined) continue
|
|
2098
|
+
if (key === 'assigneeAdd') {
|
|
2099
|
+
issue.assigneeIds = [...new Set([...issue.assigneeIds, ...(value as Uid[])])]
|
|
2100
|
+
} else if (key === 'assigneeRemove') {
|
|
2101
|
+
issue.assigneeIds = issue.assigneeIds.filter((a) => !(value as UserId[]).includes(a))
|
|
2102
|
+
} else if (key === 'labelAdd') {
|
|
2103
|
+
issue.labelIds = [...new Set([...issue.labelIds, ...(value as string[])])]
|
|
2104
|
+
} else if (key === 'labelRemove') {
|
|
2105
|
+
issue.labelIds = issue.labelIds.filter((l) => !(value as string[]).includes(l))
|
|
2106
|
+
} else if (key === 'custom') {
|
|
2107
|
+
issue.custom = { ...issue.custom, ...(value as Record<string, unknown>) }
|
|
2108
|
+
} else if (key === 'description') {
|
|
2109
|
+
issue.description = value as Issue['description']
|
|
2110
|
+
issue.descriptionText = plainText(issue.description)
|
|
2111
|
+
} else {
|
|
2112
|
+
;(issue as unknown as Record<string, unknown>)[key] = value
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
touch(issue)
|
|
2116
|
+
return clone(issue)
|
|
2117
|
+
},
|
|
2118
|
+
|
|
2119
|
+
move: async ({ issueId, projectId }: Ws & { issueId: string; projectId: string }) => {
|
|
2120
|
+
const issue = find(issueId)
|
|
2121
|
+
const project = projects.find((p) => p.id === projectId)
|
|
2122
|
+
if (!project) throw new Error(`[mock] unknown project ${projectId}`)
|
|
2123
|
+
// The key belongs to the project, so moving re-keys the issue — the same thing the server
|
|
2124
|
+
// does, and the reason the panel has to follow the new key afterwards.
|
|
2125
|
+
const next = (state.counters.get(projectId) ?? 0) + 1
|
|
2126
|
+
state.counters.set(projectId, next)
|
|
2127
|
+
issue.projectId = projectId
|
|
2128
|
+
issue.key = `${project.key}-${next}`
|
|
2129
|
+
issue.number = next
|
|
2130
|
+
// Relations render by key through this map, so a stale entry would show the old key on
|
|
2131
|
+
// every issue that points at this one.
|
|
2132
|
+
issueKeyById.set(issue.id, issue.key)
|
|
2133
|
+
// Nothing project-scoped survives the move; the server clears these for the same reason.
|
|
2134
|
+
issue.componentIds = []
|
|
2135
|
+
issue.versionIds = []
|
|
2136
|
+
issue.cycleId = null
|
|
2137
|
+
issue.milestoneId = null
|
|
2138
|
+
touch(issue)
|
|
2139
|
+
return clone(issue)
|
|
2140
|
+
},
|
|
2141
|
+
|
|
2142
|
+
bulkUpdate: async ({ ids, patch }: Ws & { ids: string[]; patch: Record<string, unknown> }) => {
|
|
2143
|
+
const results = ids.map((id) => {
|
|
2144
|
+
const issue = state.issues.find((i) => i.id === id)
|
|
2145
|
+
if (!issue) return { id, ok: false, error: { code: 'NOT_FOUND', message: 'Unknown issue' } }
|
|
2146
|
+
Object.assign(issue, patch)
|
|
2147
|
+
touch(issue)
|
|
2148
|
+
return { id, ok: true }
|
|
2149
|
+
})
|
|
2150
|
+
return {
|
|
2151
|
+
results,
|
|
2152
|
+
succeeded: results.filter((r) => r.ok).length,
|
|
2153
|
+
failed: results.filter((r) => !r.ok).length,
|
|
2154
|
+
}
|
|
2155
|
+
},
|
|
2156
|
+
|
|
2157
|
+
rank: async ({
|
|
2158
|
+
issueId,
|
|
2159
|
+
afterId,
|
|
2160
|
+
beforeId,
|
|
2161
|
+
}: Ws & {
|
|
2162
|
+
issueId: string
|
|
2163
|
+
afterId?: string
|
|
2164
|
+
beforeId?: string
|
|
2165
|
+
}) => {
|
|
2166
|
+
const issue = find(issueId)
|
|
2167
|
+
const after = afterId ? find(afterId).rank : null
|
|
2168
|
+
const before = beforeId ? find(beforeId).rank : null
|
|
2169
|
+
issue.rank = rankBetweenSafe(after, before)
|
|
2170
|
+
touch(issue)
|
|
2171
|
+
return { id: issue.id, rank: issue.rank }
|
|
2172
|
+
},
|
|
2173
|
+
|
|
2174
|
+
archive: async ({ issueId, archived = true }: Ws & { issueId: string; archived?: boolean }) => {
|
|
2175
|
+
const issue = find(issueId)
|
|
2176
|
+
issue.archivedAt = archived ? new Date().toISOString() : null
|
|
2177
|
+
touch(issue)
|
|
2178
|
+
return clone(issue)
|
|
2179
|
+
},
|
|
2180
|
+
|
|
2181
|
+
delete: async ({ issueId }: Ws & { issueId: string }) => {
|
|
2182
|
+
state.issues = state.issues.filter((i) => i.id !== issueId)
|
|
2183
|
+
// What the screen promises goes with it: the server cascades these, so the demo must too.
|
|
2184
|
+
state.comments = state.comments.filter((c) => c.issueId !== issueId)
|
|
2185
|
+
state.attachments = state.attachments.filter((a) => a.issueId !== issueId)
|
|
2186
|
+
return { ok: true as const }
|
|
2187
|
+
},
|
|
2188
|
+
|
|
2189
|
+
history: async ({ issueId }: Ws & { issueId: string }) => ({
|
|
2190
|
+
items: clone(state.history.filter((h) => h.issueId === issueId)),
|
|
2191
|
+
statusHistory: [],
|
|
2192
|
+
nextCursor: null,
|
|
2193
|
+
}),
|
|
2194
|
+
|
|
2195
|
+
watchers: {
|
|
2196
|
+
add: async ({ issueId, userId }: Ws & { issueId: string; userId?: Uid }) => {
|
|
2197
|
+
const issue = find(issueId)
|
|
2198
|
+
issue.watcherIds = [...new Set([...issue.watcherIds, userId ?? ME])]
|
|
2199
|
+
return { watcherIds: [...issue.watcherIds] }
|
|
2200
|
+
},
|
|
2201
|
+
remove: async ({ issueId, userId }: Ws & { issueId: string; userId: Uid }) => {
|
|
2202
|
+
const issue = find(issueId)
|
|
2203
|
+
issue.watcherIds = issue.watcherIds.filter((w) => w !== userId)
|
|
2204
|
+
return { watcherIds: [...issue.watcherIds] }
|
|
2205
|
+
},
|
|
2206
|
+
},
|
|
2207
|
+
|
|
2208
|
+
transitions: {
|
|
2209
|
+
available: async ({ issueId }: Ws & { issueId: string }) => {
|
|
2210
|
+
const issue = find(issueId)
|
|
2211
|
+
return statuses
|
|
2212
|
+
.filter((s) => s.id !== issue.statusId)
|
|
2213
|
+
.map((s) => ({
|
|
2214
|
+
id: `to:${s.id}`,
|
|
2215
|
+
name: s.name,
|
|
2216
|
+
toStatusId: s.id,
|
|
2217
|
+
toStatus: clone(s),
|
|
2218
|
+
allowed: true,
|
|
2219
|
+
reasons: [],
|
|
2220
|
+
// Closing needs a sign-off in the demo, so an approval is something you can see
|
|
2221
|
+
// and decide rather than a screen nobody ever reaches.
|
|
2222
|
+
requiresApproval: s.category === 'done',
|
|
2223
|
+
screen: null,
|
|
2224
|
+
hidden: false,
|
|
2225
|
+
}))
|
|
2226
|
+
},
|
|
2227
|
+
apply: async ({ issueId, transitionId }: Ws & { issueId: string; transitionId: string }) => {
|
|
2228
|
+
const issue = find(issueId)
|
|
2229
|
+
const target = statusById.get(transitionId.replace(/^to:/, ''))
|
|
2230
|
+
if (!target) throw new Error(`[mock] unknown transition ${transitionId}`)
|
|
2231
|
+
if (target.category === 'done') {
|
|
2232
|
+
const existing = state.approvals.find(
|
|
2233
|
+
(a) => a.issueId === issue.id && a.state.transitionId === transitionId,
|
|
2234
|
+
)
|
|
2235
|
+
if (existing?.state.status !== 'approved') {
|
|
2236
|
+
// Park it: the issue stays where it is until somebody signs off.
|
|
2237
|
+
const approval = existing ?? newApproval(issue.id, transitionId)
|
|
2238
|
+
if (!existing) state.approvals.push(approval)
|
|
2239
|
+
return { issue: clone(issue), approval: clone(approval) }
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
state.history.push({
|
|
2243
|
+
id: uid(1200 + state.history.length),
|
|
2244
|
+
issueId: issue.id,
|
|
2245
|
+
actorId: ME,
|
|
2246
|
+
action: 'status_changed',
|
|
2247
|
+
changes: [{ field: 'status', from: issue.statusId, to: target.id }],
|
|
2248
|
+
data: {},
|
|
2249
|
+
occurredAt: new Date().toISOString(),
|
|
2250
|
+
})
|
|
2251
|
+
issue.statusId = target.id
|
|
2252
|
+
issue.statusCategory = target.category
|
|
2253
|
+
issue.triage = target.category === 'triage'
|
|
2254
|
+
issue.completedAt = target.category === 'done' ? new Date().toISOString() : null
|
|
2255
|
+
issue.cancelledAt = target.category === 'cancelled' ? new Date().toISOString() : null
|
|
2256
|
+
touch(issue)
|
|
2257
|
+
return { issue: clone(issue), approval: null }
|
|
2258
|
+
},
|
|
2259
|
+
},
|
|
2260
|
+
|
|
2261
|
+
templates: {
|
|
2262
|
+
// Filtered, because a template belongs to one project and the project's own page asks for
|
|
2263
|
+
// its own: an unfiltered list showed another team's templates as if they were yours.
|
|
2264
|
+
list: async ({ projectId }: Ws & { projectId?: string }) =>
|
|
2265
|
+
clone(state.issueTemplates.filter((t) => !projectId || t.projectId === projectId)),
|
|
2266
|
+
create: async (
|
|
2267
|
+
input: Ws & {
|
|
2268
|
+
name: string
|
|
2269
|
+
projectId?: string | null
|
|
2270
|
+
description?: string | null
|
|
2271
|
+
defaults?: IssueTemplate['defaults']
|
|
2272
|
+
},
|
|
2273
|
+
) => {
|
|
2274
|
+
const now = new Date().toISOString()
|
|
2275
|
+
const template: IssueTemplate = {
|
|
2276
|
+
// Its own range: 2600 is the milestones', and two objects sharing an id made one of
|
|
2277
|
+
// them unremovable, since a delete matches by id alone.
|
|
2278
|
+
id: uid(3100 + state.issueTemplates.length),
|
|
2279
|
+
workspaceId: WORKSPACE,
|
|
2280
|
+
projectId: input.projectId ?? null,
|
|
2281
|
+
name: input.name,
|
|
2282
|
+
description: input.description ?? null,
|
|
2283
|
+
typeId: null,
|
|
2284
|
+
defaults: input.defaults ?? {},
|
|
2285
|
+
subItems: [],
|
|
2286
|
+
createdBy: ME,
|
|
2287
|
+
createdAt: now,
|
|
2288
|
+
updatedAt: now,
|
|
2289
|
+
}
|
|
2290
|
+
state.issueTemplates.push(template)
|
|
2291
|
+
return clone(template)
|
|
2292
|
+
},
|
|
2293
|
+
update: async ({ id, patch }: Ws & { id: string; patch: Partial<IssueTemplate> }) => {
|
|
2294
|
+
const template = state.issueTemplates.find((t) => t.id === id)
|
|
2295
|
+
if (!template) throw new Error(`[mock] unknown issue template ${id}`)
|
|
2296
|
+
Object.assign(template, patch, { updatedAt: new Date().toISOString() })
|
|
2297
|
+
return clone(template)
|
|
2298
|
+
},
|
|
2299
|
+
delete: async ({ id }: Ws & { id: string }) => {
|
|
2300
|
+
state.issueTemplates = state.issueTemplates.filter((t) => t.id !== id)
|
|
2301
|
+
return { ok: true as const }
|
|
2302
|
+
},
|
|
2303
|
+
},
|
|
2304
|
+
|
|
2305
|
+
recurring: {
|
|
2306
|
+
list: async ({ projectId }: Ws & { projectId: string }) =>
|
|
2307
|
+
clone(state.recurring.filter((r) => r.projectId === projectId)),
|
|
2308
|
+
create: async (input: Ws & { projectId: string; name: string; rule: RecurrenceRule }) => {
|
|
2309
|
+
const now = new Date().toISOString()
|
|
2310
|
+
const entry: RecurringIssue = {
|
|
2311
|
+
id: uid(2700 + state.recurring.length),
|
|
2312
|
+
workspaceId: WORKSPACE,
|
|
2313
|
+
projectId: input.projectId,
|
|
2314
|
+
name: input.name,
|
|
2315
|
+
rule: input.rule,
|
|
2316
|
+
defaults: { title: input.name },
|
|
2317
|
+
enabled: true,
|
|
2318
|
+
// The real scheduler computes this; the mock says "tomorrow", which is enough for the
|
|
2319
|
+
// row to show something a person can sanity-check.
|
|
2320
|
+
nextRunAt: iso(-DAY),
|
|
2321
|
+
lastRunAt: null,
|
|
2322
|
+
lastIssueId: null,
|
|
2323
|
+
runCount: 0,
|
|
2324
|
+
createdBy: ME,
|
|
2325
|
+
createdAt: now,
|
|
2326
|
+
updatedAt: now,
|
|
2327
|
+
}
|
|
2328
|
+
state.recurring.push(entry)
|
|
2329
|
+
return clone(entry)
|
|
2330
|
+
},
|
|
2331
|
+
update: async ({ id, patch }: Ws & { id: string; patch: Partial<RecurringIssue> }) => {
|
|
2332
|
+
const entry = state.recurring.find((r) => r.id === id)
|
|
2333
|
+
if (!entry) throw new Error(`[mock] unknown recurring issue ${id}`)
|
|
2334
|
+
Object.assign(entry, patch, { updatedAt: new Date().toISOString() })
|
|
2335
|
+
return clone(entry)
|
|
2336
|
+
},
|
|
2337
|
+
delete: async ({ id }: Ws & { id: string }) => {
|
|
2338
|
+
state.recurring = state.recurring.filter((r) => r.id !== id)
|
|
2339
|
+
return { ok: true as const }
|
|
2340
|
+
},
|
|
2341
|
+
},
|
|
2342
|
+
|
|
2343
|
+
comments: {
|
|
2344
|
+
list: async ({ issueId }: Ws & { issueId: string }) => ({
|
|
2345
|
+
items: clone(
|
|
2346
|
+
state.comments
|
|
2347
|
+
.filter((c) => c.issueId === issueId)
|
|
2348
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt)),
|
|
2349
|
+
),
|
|
2350
|
+
nextCursor: null,
|
|
2351
|
+
}),
|
|
2352
|
+
create: async ({
|
|
2353
|
+
issueId,
|
|
2354
|
+
body,
|
|
2355
|
+
parentId,
|
|
2356
|
+
internal,
|
|
2357
|
+
}: Ws & {
|
|
2358
|
+
issueId: string
|
|
2359
|
+
body: Comment['body']
|
|
2360
|
+
parentId?: string | null
|
|
2361
|
+
internal?: boolean
|
|
2362
|
+
}) => {
|
|
2363
|
+
const issue = find(issueId)
|
|
2364
|
+
const comment: Comment = {
|
|
2365
|
+
id: uid(1100 + state.comments.length),
|
|
2366
|
+
workspaceId: WORKSPACE,
|
|
2367
|
+
issueId,
|
|
2368
|
+
parentId: parentId ?? null,
|
|
2369
|
+
authorId: ME,
|
|
2370
|
+
body,
|
|
2371
|
+
bodyText: plainText(body),
|
|
2372
|
+
mentionIds: [],
|
|
2373
|
+
reactions: [],
|
|
2374
|
+
internal: internal ?? false,
|
|
2375
|
+
source: 'app',
|
|
2376
|
+
replyCount: 0,
|
|
2377
|
+
editedAt: null,
|
|
2378
|
+
deletedAt: null,
|
|
2379
|
+
createdAt: new Date().toISOString(),
|
|
2380
|
+
updatedAt: new Date().toISOString(),
|
|
2381
|
+
}
|
|
2382
|
+
state.comments.push(comment)
|
|
2383
|
+
issue.commentCount += 1
|
|
2384
|
+
if (comment.parentId) {
|
|
2385
|
+
const parent = state.comments.find((c) => c.id === comment.parentId)
|
|
2386
|
+
if (parent) parent.replyCount += 1
|
|
2387
|
+
}
|
|
2388
|
+
touch(issue)
|
|
2389
|
+
return clone(comment)
|
|
2390
|
+
},
|
|
2391
|
+
update: async ({ commentId, body }: Ws & { commentId: string; body: Comment['body'] }) => {
|
|
2392
|
+
const comment = state.comments.find((c) => c.id === commentId)
|
|
2393
|
+
if (!comment) throw new Error(`[mock] unknown comment ${commentId}`)
|
|
2394
|
+
comment.body = body
|
|
2395
|
+
comment.bodyText = plainText(body)
|
|
2396
|
+
comment.editedAt = new Date().toISOString()
|
|
2397
|
+
comment.updatedAt = comment.editedAt
|
|
2398
|
+
return clone(comment)
|
|
2399
|
+
},
|
|
2400
|
+
react: async ({ commentId, emoji }: Ws & { commentId: string; emoji: string }) => {
|
|
2401
|
+
const comment = state.comments.find((c) => c.id === commentId)
|
|
2402
|
+
if (!comment) throw new Error(`[mock] unknown comment ${commentId}`)
|
|
2403
|
+
const existing = comment.reactions.find((r) => r.emoji === emoji)
|
|
2404
|
+
if (!existing) {
|
|
2405
|
+
comment.reactions = [...comment.reactions, { emoji, count: 1, userIds: [ME] }]
|
|
2406
|
+
} else if (existing.userIds.includes(ME)) {
|
|
2407
|
+
existing.userIds = existing.userIds.filter((u) => u !== ME)
|
|
2408
|
+
existing.count = existing.userIds.length
|
|
2409
|
+
comment.reactions = comment.reactions.filter((r) => r.count > 0)
|
|
2410
|
+
} else {
|
|
2411
|
+
existing.userIds = [...existing.userIds, ME]
|
|
2412
|
+
existing.count = existing.userIds.length
|
|
2413
|
+
}
|
|
2414
|
+
return clone(comment)
|
|
2415
|
+
},
|
|
2416
|
+
delete: async ({ commentId }: Ws & { commentId: string }) => {
|
|
2417
|
+
const comment = state.comments.find((c) => c.id === commentId)
|
|
2418
|
+
state.comments = state.comments.filter((c) => c.id !== commentId)
|
|
2419
|
+
// The files that arrived with it go too: nothing else points at them.
|
|
2420
|
+
state.attachments = state.attachments.filter((a) => a.commentId !== commentId)
|
|
2421
|
+
if (comment) {
|
|
2422
|
+
const issue = state.issues.find((i) => i.id === comment.issueId)
|
|
2423
|
+
if (issue) issue.commentCount = Math.max(0, issue.commentCount - 1)
|
|
2424
|
+
}
|
|
2425
|
+
return { ok: true as const }
|
|
2426
|
+
},
|
|
2427
|
+
},
|
|
2428
|
+
approvals: {
|
|
2429
|
+
list: async ({ issueId }: Ws & { issueId: string }) =>
|
|
2430
|
+
clone(state.approvals.filter((a) => a.issueId === issueId)),
|
|
2431
|
+
decide: async ({
|
|
2432
|
+
issueId,
|
|
2433
|
+
transitionId,
|
|
2434
|
+
decision,
|
|
2435
|
+
comment,
|
|
2436
|
+
}: Ws & {
|
|
2437
|
+
issueId: string
|
|
2438
|
+
transitionId: string
|
|
2439
|
+
decision: 'approve' | 'reject'
|
|
2440
|
+
comment?: string
|
|
2441
|
+
}) => {
|
|
2442
|
+
const approval = state.approvals.find(
|
|
2443
|
+
(a) => a.issueId === issueId && a.state.transitionId === transitionId,
|
|
2444
|
+
)
|
|
2445
|
+
if (!approval) throw new Error(`[mock] no approval for ${transitionId}`)
|
|
2446
|
+
approval.state.decisions = [
|
|
2447
|
+
...approval.state.decisions,
|
|
2448
|
+
{ userId: ME, decision, comment: comment ?? null, at: new Date().toISOString() },
|
|
2449
|
+
]
|
|
2450
|
+
const approved = approval.state.decisions.filter(
|
|
2451
|
+
(d: { decision: string }) => d.decision === 'approve',
|
|
2452
|
+
).length
|
|
2453
|
+
approval.state.status =
|
|
2454
|
+
decision === 'reject' && approval.state.spec.rejectOnAnyRejection
|
|
2455
|
+
? 'rejected'
|
|
2456
|
+
: approved >= approval.state.spec.minApprovals
|
|
2457
|
+
? 'approved'
|
|
2458
|
+
: 'pending'
|
|
2459
|
+
approval.updatedAt = new Date().toISOString()
|
|
2460
|
+
|
|
2461
|
+
// An approval that completes applies the transition it was blocking.
|
|
2462
|
+
let issue: Issue | null = null
|
|
2463
|
+
if (approval.state.status === 'approved') {
|
|
2464
|
+
const target = statusById.get(transitionId.replace(/^to:/, ''))
|
|
2465
|
+
const row = find(issueId)
|
|
2466
|
+
if (target) {
|
|
2467
|
+
row.statusId = target.id
|
|
2468
|
+
row.statusCategory = target.category
|
|
2469
|
+
row.completedAt = new Date().toISOString()
|
|
2470
|
+
touch(row)
|
|
2471
|
+
}
|
|
2472
|
+
issue = clone(row)
|
|
2473
|
+
}
|
|
2474
|
+
return { approval: clone(approval), issue }
|
|
2475
|
+
},
|
|
2476
|
+
},
|
|
2477
|
+
relations: {
|
|
2478
|
+
list: async ({ issueId }: Ws & { issueId: string }) =>
|
|
2479
|
+
clone(state.relations.filter((r) => relationOwner.get(r.id) === issueId)),
|
|
2480
|
+
create: async ({
|
|
2481
|
+
issueId,
|
|
2482
|
+
type,
|
|
2483
|
+
targetIssueId,
|
|
2484
|
+
}: Ws & { issueId: string; type: RelationType; targetIssueId: string }) => {
|
|
2485
|
+
const target = find(targetIssueId)
|
|
2486
|
+
const source = find(issueId)
|
|
2487
|
+
const id = uid(1500 + state.relations.length)
|
|
2488
|
+
const inverseId = uid(1500 + state.relations.length + 1)
|
|
2489
|
+
const at = new Date().toISOString()
|
|
2490
|
+
// Both directions, the way the server writes them: an issue that blocks another is
|
|
2491
|
+
// blocked-by from the other side, and each end lists its own view.
|
|
2492
|
+
state.relations.push({ id, type, issue: summaryOf(target), createdAt: at })
|
|
2493
|
+
relationOwner.set(id, issueId)
|
|
2494
|
+
state.relations.push({
|
|
2495
|
+
id: inverseId,
|
|
2496
|
+
type: RELATION_INVERSE[type],
|
|
2497
|
+
issue: summaryOf(source),
|
|
2498
|
+
createdAt: at,
|
|
2499
|
+
})
|
|
2500
|
+
relationOwner.set(inverseId, targetIssueId)
|
|
2501
|
+
inverseOf.set(id, inverseId)
|
|
2502
|
+
inverseOf.set(inverseId, id)
|
|
2503
|
+
return clone(state.relations.filter((r) => relationOwner.get(r.id) === issueId))
|
|
2504
|
+
},
|
|
2505
|
+
delete: async ({ relationId }: Ws & { relationId: string }) => {
|
|
2506
|
+
const partner = inverseOf.get(relationId)
|
|
2507
|
+
state.relations = state.relations.filter((r) => r.id !== relationId && r.id !== partner)
|
|
2508
|
+
return { ok: true as const }
|
|
2509
|
+
},
|
|
2510
|
+
},
|
|
2511
|
+
links: {
|
|
2512
|
+
list: async ({ issueId }: Ws & { issueId: string }) =>
|
|
2513
|
+
clone(state.links.filter((l) => l.issueId === issueId)),
|
|
2514
|
+
add: async ({
|
|
2515
|
+
issueId,
|
|
2516
|
+
url,
|
|
2517
|
+
title,
|
|
2518
|
+
kind,
|
|
2519
|
+
}: Ws & { issueId: string; url: string; title?: string; kind?: string }) => {
|
|
2520
|
+
const link: Link = {
|
|
2521
|
+
id: uid(1600 + state.links.length),
|
|
2522
|
+
workspaceId: WORKSPACE,
|
|
2523
|
+
issueId,
|
|
2524
|
+
url,
|
|
2525
|
+
title: title ?? null,
|
|
2526
|
+
kind: kind ?? 'generic',
|
|
2527
|
+
createdBy: ME,
|
|
2528
|
+
createdAt: new Date().toISOString(),
|
|
2529
|
+
}
|
|
2530
|
+
state.links.push(link)
|
|
2531
|
+
return clone(link)
|
|
2532
|
+
},
|
|
2533
|
+
remove: async ({ linkId }: Ws & { linkId: string }) => {
|
|
2534
|
+
state.links = state.links.filter((l) => l.id !== linkId)
|
|
2535
|
+
return { ok: true as const }
|
|
2536
|
+
},
|
|
2537
|
+
},
|
|
2538
|
+
attachments: {
|
|
2539
|
+
list: async ({ issueId }: Ws & { issueId: string }) =>
|
|
2540
|
+
clone(state.attachments.filter((a) => a.issueId === issueId)),
|
|
2541
|
+
add: async ({
|
|
2542
|
+
issueId,
|
|
2543
|
+
fileIds,
|
|
2544
|
+
commentId,
|
|
2545
|
+
}: Ws & { issueId: string; fileIds: string[]; commentId?: string | null }) => {
|
|
2546
|
+
const issue = find(issueId)
|
|
2547
|
+
const added: Attachment[] = fileIds.map((fileId, i) => ({
|
|
2548
|
+
id: uid(1400 + state.attachments.length + i),
|
|
2549
|
+
workspaceId: WORKSPACE,
|
|
2550
|
+
issueId,
|
|
2551
|
+
commentId: commentId ?? null,
|
|
2552
|
+
fileId,
|
|
2553
|
+
name: mockFileName(fileId),
|
|
2554
|
+
mimeType: 'application/octet-stream',
|
|
2555
|
+
size: 24_000,
|
|
2556
|
+
uploadedBy: ME,
|
|
2557
|
+
createdAt: new Date().toISOString(),
|
|
2558
|
+
}))
|
|
2559
|
+
state.attachments = [...state.attachments, ...added]
|
|
2560
|
+
issue.attachmentCount = state.attachments.filter((a) => a.issueId === issueId).length
|
|
2561
|
+
touch(issue)
|
|
2562
|
+
return clone(added)
|
|
2563
|
+
},
|
|
2564
|
+
remove: async ({ attachmentId }: Ws & { attachmentId: string }) => {
|
|
2565
|
+
const gone = state.attachments.find((a) => a.id === attachmentId)
|
|
2566
|
+
state.attachments = state.attachments.filter((a) => a.id !== attachmentId)
|
|
2567
|
+
if (gone) {
|
|
2568
|
+
const issue = state.issues.find((i) => i.id === gone.issueId)
|
|
2569
|
+
if (issue)
|
|
2570
|
+
issue.attachmentCount = state.attachments.filter((a) => a.issueId === gone.issueId).length
|
|
2571
|
+
}
|
|
2572
|
+
return { ok: true as const }
|
|
2573
|
+
},
|
|
2574
|
+
},
|
|
2575
|
+
},
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/**
|
|
2580
|
+
* The other side of each relation. The real server owns this mapping and returns both views; the
|
|
2581
|
+
* mock has to write both halves itself, and the client entry exports types only, so it is repeated
|
|
2582
|
+
* here rather than imported from the contract.
|
|
2583
|
+
*/
|
|
2584
|
+
const RELATION_INVERSE: Record<RelationType, RelationType> = {
|
|
2585
|
+
blocks: 'blocked_by',
|
|
2586
|
+
blocked_by: 'blocks',
|
|
2587
|
+
relates: 'relates',
|
|
2588
|
+
duplicates: 'duplicated_by',
|
|
2589
|
+
duplicated_by: 'duplicates',
|
|
2590
|
+
clones: 'cloned_by',
|
|
2591
|
+
cloned_by: 'clones',
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
/** A fresh pending approval, shaped the way the workflow engine writes one. */
|
|
2595
|
+
function newApproval(issueId: string, transitionId: string): IssueApproval {
|
|
2596
|
+
const at = new Date().toISOString()
|
|
2597
|
+
return {
|
|
2598
|
+
id: uid(1700 + approvalCounter++),
|
|
2599
|
+
workspaceId: WORKSPACE,
|
|
2600
|
+
issueId,
|
|
2601
|
+
transitionId,
|
|
2602
|
+
state: {
|
|
2603
|
+
transitionId,
|
|
2604
|
+
spec: { approvers: [{ kind: 'role', id: 'admin' }], minApprovals: 1, rejectOnAnyRejection: true },
|
|
2605
|
+
requestedBy: ME,
|
|
2606
|
+
requestedAt: at,
|
|
2607
|
+
decisions: [],
|
|
2608
|
+
status: 'pending',
|
|
2609
|
+
},
|
|
2610
|
+
createdAt: at,
|
|
2611
|
+
updatedAt: at,
|
|
2612
|
+
} as IssueApproval
|
|
2613
|
+
}
|
|
2614
|
+
let approvalCounter = 0
|
|
2615
|
+
|
|
2616
|
+
/** Which issue each relation view belongs to, and which view is its other half. */
|
|
2617
|
+
const relationOwner = new Map<string, string>()
|
|
2618
|
+
const inverseOf = new Map<string, string>()
|
|
2619
|
+
|
|
2620
|
+
/** The subset of an issue a relation shows. */
|
|
2621
|
+
function summaryOf(issue: Issue): RelationView['issue'] {
|
|
2622
|
+
return {
|
|
2623
|
+
id: issue.id,
|
|
2624
|
+
workspaceId: issue.workspaceId,
|
|
2625
|
+
projectId: issue.projectId,
|
|
2626
|
+
key: issue.key,
|
|
2627
|
+
number: issue.number,
|
|
2628
|
+
typeId: issue.typeId,
|
|
2629
|
+
title: issue.title,
|
|
2630
|
+
statusId: issue.statusId,
|
|
2631
|
+
statusCategory: issue.statusCategory,
|
|
2632
|
+
priority: issue.priority,
|
|
2633
|
+
assigneeIds: issue.assigneeIds,
|
|
2634
|
+
labelIds: issue.labelIds,
|
|
2635
|
+
cycleId: issue.cycleId,
|
|
2636
|
+
parentId: issue.parentId,
|
|
2637
|
+
} as RelationView['issue']
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
/** The mock uploader keeps bytes in memory and names them by id; show something readable. */
|
|
2641
|
+
function mockFileName(fileId: string): string {
|
|
2642
|
+
return `file-${fileId.slice(0, 8)}`
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
function plainText(doc: Issue['description'] | Comment['body']): string {
|
|
2646
|
+
const walk = (node: unknown): string => {
|
|
2647
|
+
if (!node || typeof node !== 'object') return ''
|
|
2648
|
+
const n = node as { text?: string; content?: unknown[] }
|
|
2649
|
+
if (typeof n.text === 'string') return n.text
|
|
2650
|
+
return (n.content ?? []).map(walk).join(' ')
|
|
2651
|
+
}
|
|
2652
|
+
return doc ? walk(doc).trim() : ''
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
/** A drop can land next to an equal rank after a concurrent edit; a fresh key is better than a throw. */
|
|
2656
|
+
function rankBetweenSafe(before: string | null, after: string | null): string {
|
|
2657
|
+
try {
|
|
2658
|
+
return rankBetween(before, after)
|
|
2659
|
+
} catch {
|
|
2660
|
+
return rankBetween(null, null)
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
const SYSTEM_FIELD_INFO = [
|
|
2665
|
+
{
|
|
2666
|
+
name: 'project',
|
|
2667
|
+
type: 'ref',
|
|
2668
|
+
label: 'Project',
|
|
2669
|
+
operators: ['=', '!=', 'in', 'not-in'],
|
|
2670
|
+
custom: false,
|
|
2671
|
+
sortable: true,
|
|
2672
|
+
},
|
|
2673
|
+
{
|
|
2674
|
+
name: 'status',
|
|
2675
|
+
type: 'ref',
|
|
2676
|
+
label: 'Status',
|
|
2677
|
+
operators: ['=', '!=', 'in', 'not-in'],
|
|
2678
|
+
custom: false,
|
|
2679
|
+
sortable: true,
|
|
2680
|
+
},
|
|
2681
|
+
{
|
|
2682
|
+
name: 'priority',
|
|
2683
|
+
type: 'enum',
|
|
2684
|
+
label: 'Priority',
|
|
2685
|
+
operators: ['=', '!=', 'in', 'not-in'],
|
|
2686
|
+
custom: false,
|
|
2687
|
+
sortable: true,
|
|
2688
|
+
values: [
|
|
2689
|
+
{ value: 'urgent', label: 'Urgent' },
|
|
2690
|
+
{ value: 'high', label: 'High' },
|
|
2691
|
+
{ value: 'medium', label: 'Medium' },
|
|
2692
|
+
{ value: 'low', label: 'Low' },
|
|
2693
|
+
{ value: 'none', label: 'None' },
|
|
2694
|
+
],
|
|
2695
|
+
},
|
|
2696
|
+
{
|
|
2697
|
+
name: 'assignee',
|
|
2698
|
+
type: 'user',
|
|
2699
|
+
label: 'Assignee',
|
|
2700
|
+
operators: ['=', '!=', 'in', 'not-in', 'is-empty', 'is-not-empty'],
|
|
2701
|
+
custom: false,
|
|
2702
|
+
sortable: false,
|
|
2703
|
+
},
|
|
2704
|
+
{
|
|
2705
|
+
name: 'label',
|
|
2706
|
+
type: 'ref',
|
|
2707
|
+
label: 'Label',
|
|
2708
|
+
operators: ['=', '!=', 'in', 'not-in', 'is-empty'],
|
|
2709
|
+
custom: false,
|
|
2710
|
+
sortable: false,
|
|
2711
|
+
},
|
|
2712
|
+
{
|
|
2713
|
+
name: 'cycle',
|
|
2714
|
+
type: 'ref',
|
|
2715
|
+
label: 'Cycle',
|
|
2716
|
+
operators: ['=', '!=', 'in', 'not-in'],
|
|
2717
|
+
custom: false,
|
|
2718
|
+
sortable: true,
|
|
2719
|
+
},
|
|
2720
|
+
{
|
|
2721
|
+
name: 'due',
|
|
2722
|
+
type: 'date',
|
|
2723
|
+
label: 'Due date',
|
|
2724
|
+
operators: ['=', '!=', '<', '<=', '>', '>='],
|
|
2725
|
+
custom: false,
|
|
2726
|
+
sortable: true,
|
|
2727
|
+
},
|
|
2728
|
+
{ name: 'text', type: 'text', label: 'Full text', operators: ['~', '!~'], custom: false, sortable: false },
|
|
2729
|
+
]
|