@kud/jira-ink 0.2.1 → 0.3.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/README.md +46 -3
- package/dist/index.d.ts +170 -2
- package/dist/index.js +484 -7
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# @kud/jira-ink
|
|
2
2
|
|
|
3
3
|
Controlled [Ink](https://github.com/vadimdemedes/ink) components for viewing
|
|
4
|
-
Jira issues in the terminal — the issue detail screen, extracted
|
|
5
|
-
[`@kud/jira-cli`](https://github.com/kud/jira-cli)'s TUI so
|
|
6
|
-
with ambre's cockpit.
|
|
4
|
+
Jira issues in the terminal — the board and the issue detail screen, extracted
|
|
5
|
+
from [`@kud/jira-cli`](https://github.com/kud/jira-cli)'s TUI so they can be
|
|
6
|
+
shared with ambre's cockpit.
|
|
7
7
|
|
|
8
8
|
Every component is **presentation-only**: props in, no data fetching, no
|
|
9
9
|
app-level input. The consuming surface owns selection, navigation and loading,
|
|
@@ -71,6 +71,49 @@ const issue = await issueDetailOf(client, baseUrl, "SHOP-1234")
|
|
|
71
71
|
| `issueDetailOf` | Fetches one issue through a `@kud/jira` client into the view-ready shape |
|
|
72
72
|
| `transitionsOf` | The issue's available status transitions, `{ id, name, to }` |
|
|
73
73
|
| `IssueDetail` | The shape the view reads: strings throughout, ADF already Markdown |
|
|
74
|
+
|
|
75
|
+
### The board
|
|
76
|
+
|
|
77
|
+
A tabbed list of issues: rows grouped under their epic, a type pill, a priority glyph and an age on each, and a `/` search that takes plain words or JQL. The host owns the frame, the fetch and the quit key; the board owns only the layers it pushes itself (its search box and its legend).
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
import { boardOf, IssueBoard } from "@kud/jira-ink"
|
|
81
|
+
|
|
82
|
+
const model = await boardOf(client, "assignee = currentUser() ORDER BY updated DESC", {
|
|
83
|
+
board: config.defaultBoard, // optional — its columns become the tabs
|
|
84
|
+
tabs: config.tabs, // optional — hand-written tabs win over the board
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
<IssueBoard
|
|
88
|
+
model={model}
|
|
89
|
+
viewer="you"
|
|
90
|
+
loadedAt={Date.now()}
|
|
91
|
+
scope={{ kind: "mine" }}
|
|
92
|
+
showingAll={false}
|
|
93
|
+
searchError={null}
|
|
94
|
+
width={100}
|
|
95
|
+
height={30}
|
|
96
|
+
onOpen={openIssue}
|
|
97
|
+
onToggleAll={toggleAll}
|
|
98
|
+
onRefresh={refresh}
|
|
99
|
+
onSearch={runSearch}
|
|
100
|
+
onClearSearch={clearSearch}
|
|
101
|
+
frame={({ facts, hints, body }) => <MyFrame facts={facts} hints={hints}>{body}</MyFrame>}
|
|
102
|
+
/>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Tabs come from one of three places, in order of precedence.** Hand-written `tabs` in the config file (statuses by id or name); the board's own column configuration (`getBoardConfiguration`, statuses matched **by id**, never by name); or Jira's status categories — To do, In progress, Done — which every status on every instance carries, so the last needs no configuration and can never name one workflow's vocabulary. Statuses no configured tab claims go to a derived **Off board** tab, drawn only when it holds something; a board with no columns falls through to categories. The board supplies the *vocabulary*, never the population: the fetch stays one query, bucketed client-side.
|
|
106
|
+
|
|
107
|
+
| Export | What it is |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `IssueBoard` | The tabbed board over a `BoardModel`; a `frame` slot hands the host its title facts, hints and body |
|
|
110
|
+
| `boardOf` | Runs one JQL through a `@kud/jira` client and derives the tabs |
|
|
111
|
+
| `toBoardRow` | One `JiraIssue` → `BoardRow`, the container question answered via `isContainerType` |
|
|
112
|
+
| `tabsFromConfig` · `tabsFromBoard` · `tabsFromCategories` | The three tab sources, each a `BoardTabs` with its `source` named |
|
|
113
|
+
| `tabOf` · `countsFor` · `visibleTabs` | Which tab a row belongs to, per-tab counts, which tabs to draw |
|
|
114
|
+
| `blocksFor` | One tab's rows as drawn: container rows head their group, absent parents get a fence, orphans last, a gap between groups |
|
|
115
|
+
| `priorityGlyph` · `relativeAge` · `pillVariantFor` | Row glyphs |
|
|
116
|
+
| `mockBoard` · `MOCK_BOARD_ROWS` | An invented board for screenshots and tests |
|
|
74
117
|
## Development
|
|
75
118
|
|
|
76
119
|
```sh
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as react from 'react';
|
|
|
2
2
|
import { ReactNode } from 'react';
|
|
3
3
|
import { MarkdownTheme } from '@kud/ink-markdown';
|
|
4
4
|
import { Hint } from '@kud/ink-ui';
|
|
5
|
-
import { LocatedAttachment, JiraClient } from '@kud/jira';
|
|
5
|
+
import { LocatedAttachment, JiraClient, BoardTab as BoardTab$1, JiraBoardConfiguration, JiraIssue, SearchMode } from '@kud/jira';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* One issue, as the detail screen reads it: every field already a string, ADF
|
|
@@ -99,4 +99,172 @@ type IssueBodyProps = {
|
|
|
99
99
|
*/
|
|
100
100
|
declare const IssueBody: ({ client, baseUrl, issueKey, onExit, width, height, frame, theme, }: IssueBodyProps) => string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | react.ReactPortal | react.ReactElement<unknown, string | react.JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | react.JSX.Element | null | undefined;
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
/** Jira's own three, plus `unknown` for a status that arrives without one. */
|
|
103
|
+
type StatusCategory = "new" | "indeterminate" | "done" | "unknown";
|
|
104
|
+
/**
|
|
105
|
+
* One issue, as the board reads it: every field already a string, the
|
|
106
|
+
* container question already answered. The view never touches a raw
|
|
107
|
+
* `JiraIssue`, so a host that fakes the fetch gets a board it can predict.
|
|
108
|
+
* The field names deliberately match the cockpit's own row vocabulary
|
|
109
|
+
* (`key`, `container`, `parent`) without importing it.
|
|
110
|
+
*/
|
|
111
|
+
type BoardRow = {
|
|
112
|
+
key: string;
|
|
113
|
+
summary: string;
|
|
114
|
+
status: string;
|
|
115
|
+
statusId: string;
|
|
116
|
+
category: StatusCategory;
|
|
117
|
+
type: string;
|
|
118
|
+
container: boolean;
|
|
119
|
+
priority: string | null;
|
|
120
|
+
parent?: {
|
|
121
|
+
key: string;
|
|
122
|
+
summary: string;
|
|
123
|
+
};
|
|
124
|
+
assignee: string;
|
|
125
|
+
/** ISO timestamp, kept whole so the view can draw a relative age. */
|
|
126
|
+
updated: string;
|
|
127
|
+
};
|
|
128
|
+
declare const toBoardRow: (issue: JiraIssue) => BoardRow;
|
|
129
|
+
/**
|
|
130
|
+
* Where a board's tabs came from, in order of precedence: the user's own
|
|
131
|
+
* `tabs` in the config file, then the board's column configuration, then
|
|
132
|
+
* Jira's status categories — which every status on every instance carries,
|
|
133
|
+
* so the last one needs no configuration and can never name one workflow's
|
|
134
|
+
* vocabulary.
|
|
135
|
+
*/
|
|
136
|
+
type TabSource = "config" | "board" | "categories";
|
|
137
|
+
type BoardTab = {
|
|
138
|
+
value: string;
|
|
139
|
+
label: string;
|
|
140
|
+
/** Status ids (or, for a config tab, names) this tab claims. Absent on a category tab. */
|
|
141
|
+
statuses?: Set<string>;
|
|
142
|
+
category?: StatusCategory;
|
|
143
|
+
/** The derived tab holding statuses no other tab claims. */
|
|
144
|
+
offBoard?: boolean;
|
|
145
|
+
};
|
|
146
|
+
type BoardTabs = {
|
|
147
|
+
source: TabSource;
|
|
148
|
+
tabs: BoardTab[];
|
|
149
|
+
};
|
|
150
|
+
declare const OFF_BOARD = "__off_board__";
|
|
151
|
+
declare const tabsFromConfig: (configured: BoardTab$1[]) => BoardTabs;
|
|
152
|
+
/**
|
|
153
|
+
* Columns are matched to statuses by ID, never by name — name-matching is
|
|
154
|
+
* how a transition table once silently stopped matching anything.
|
|
155
|
+
*/
|
|
156
|
+
declare const tabsFromBoard: (configuration: JiraBoardConfiguration) => BoardTabs | null;
|
|
157
|
+
declare const tabsFromCategories: () => BoardTabs;
|
|
158
|
+
/** The tab a row belongs to. Nothing ever falls out: unclaimed rows go Off board, or In progress on a category board. */
|
|
159
|
+
declare const tabOf: (row: BoardRow, { tabs }: BoardTabs) => string;
|
|
160
|
+
declare const countsFor: (rows: BoardRow[], tabs: BoardTabs) => Record<string, number>;
|
|
161
|
+
/** Tabs worth drawing: every configured one, plus Off board only when it holds something. */
|
|
162
|
+
declare const visibleTabs: (tabs: BoardTabs, counts: Record<string, number>) => BoardTab[];
|
|
163
|
+
/**
|
|
164
|
+
* What one tab draws, in order. A container that is itself in the tab HEADS
|
|
165
|
+
* its group as a selectable row, its children hanging beneath it; a parent
|
|
166
|
+
* that is not in the tab gets a dim fence instead, because there is no row
|
|
167
|
+
* to select. Container-headed groups come first, then fenced ones, then rows
|
|
168
|
+
* with no parent — and each group after the first is preceded by a blank
|
|
169
|
+
* line. A tab where nothing has a parent and nothing is a container degrades
|
|
170
|
+
* to a plain list, never to a lone fence.
|
|
171
|
+
*/
|
|
172
|
+
type Block = {
|
|
173
|
+
kind: "gap";
|
|
174
|
+
} | {
|
|
175
|
+
kind: "fence";
|
|
176
|
+
key: string | null;
|
|
177
|
+
summary: string;
|
|
178
|
+
} | {
|
|
179
|
+
kind: "issue";
|
|
180
|
+
row: BoardRow;
|
|
181
|
+
depth: 0 | 1;
|
|
182
|
+
};
|
|
183
|
+
declare const blocksFor: (rows: BoardRow[]) => Block[];
|
|
184
|
+
/** Index into `blocks` of the n-th issue, so a cursor over issues maps to a line. */
|
|
185
|
+
declare const blockIndexOfIssue: (blocks: Block[], issueIndex: number) => number;
|
|
186
|
+
/**
|
|
187
|
+
* Priority names are per-instance, so this matches the default scheme, the
|
|
188
|
+
* common renames and the P0–P4 ladder rather than an exact list. Medium — or
|
|
189
|
+
* anything unrecognised — draws nothing: the glyph column marks the two ends
|
|
190
|
+
* only.
|
|
191
|
+
*/
|
|
192
|
+
declare const priorityGlyph: (name: string | null) => "\u25B2" | "\u25BC" | " ";
|
|
193
|
+
declare const relativeAge: (iso: string, now?: number) => string;
|
|
194
|
+
declare const pillVariantFor: (row: Pick<BoardRow, "type" | "container">) => "group" | "info" | "error" | "muted";
|
|
195
|
+
type BoardModel = {
|
|
196
|
+
rows: BoardRow[];
|
|
197
|
+
tabs: BoardTabs;
|
|
198
|
+
};
|
|
199
|
+
type BoardOptions = {
|
|
200
|
+
/** Hand-written tabs from the config file — wins over everything. */
|
|
201
|
+
tabs?: BoardTab$1[];
|
|
202
|
+
/** A board whose column configuration supplies the tabs. */
|
|
203
|
+
board?: number;
|
|
204
|
+
limit?: number;
|
|
205
|
+
};
|
|
206
|
+
/**
|
|
207
|
+
* One assignee-scoped query, bucketed client-side. The board supplies the
|
|
208
|
+
* VOCABULARY, never the population — `getBoardIssues` would drop your
|
|
209
|
+
* tickets on every other board. A board with no columns, or none given,
|
|
210
|
+
* falls through to categories.
|
|
211
|
+
*/
|
|
212
|
+
declare const boardOf: (client: JiraClient, jql: string, { tabs, board, limit }?: BoardOptions) => Promise<BoardModel>;
|
|
213
|
+
|
|
214
|
+
/** What the board is showing, named in the title row. */
|
|
215
|
+
type BoardScope = {
|
|
216
|
+
kind: "mine";
|
|
217
|
+
} | {
|
|
218
|
+
kind: "search";
|
|
219
|
+
query: string;
|
|
220
|
+
mode: "jql" | "text";
|
|
221
|
+
};
|
|
222
|
+
type IssueBoardProps = {
|
|
223
|
+
model: BoardModel;
|
|
224
|
+
/** Who the `mine` scope belongs to, for the title row. */
|
|
225
|
+
viewer: string;
|
|
226
|
+
loadedAt: number;
|
|
227
|
+
scope: BoardScope;
|
|
228
|
+
/** Whether closed work beyond the recent window has been loaded. */
|
|
229
|
+
showingAll: boolean;
|
|
230
|
+
/** Jira's own words for a query it rejected; drawn under the search box, query kept. */
|
|
231
|
+
searchError: string | null;
|
|
232
|
+
width: number;
|
|
233
|
+
height: number;
|
|
234
|
+
onOpen: (key: string) => void;
|
|
235
|
+
onToggleAll: () => void;
|
|
236
|
+
onRefresh: () => void;
|
|
237
|
+
onSearch: (query: string, mode: SearchMode) => void;
|
|
238
|
+
onClearSearch: () => void;
|
|
239
|
+
/**
|
|
240
|
+
* The host draws the chrome — border, title row, hints at the foot — and
|
|
241
|
+
* places the parts. Without it the board draws a plain column, which is
|
|
242
|
+
* what a test or an embedded pane wants.
|
|
243
|
+
*/
|
|
244
|
+
frame?: (parts: {
|
|
245
|
+
facts: string;
|
|
246
|
+
hints: Hint[];
|
|
247
|
+
body: ReactNode;
|
|
248
|
+
}) => ReactNode;
|
|
249
|
+
};
|
|
250
|
+
/**
|
|
251
|
+
* Rows are windowed by hand rather than handed to `Table`, which renders every
|
|
252
|
+
* row and has no notion of a cursor. A backlog of two hundred issues would
|
|
253
|
+
* otherwise scroll the selected row off the top of the terminal.
|
|
254
|
+
*
|
|
255
|
+
* The board binds no `q` and no back key of its own — those belong to the
|
|
256
|
+
* host — only the keys that open and close the layers it pushes itself: the
|
|
257
|
+
* search input and the legend.
|
|
258
|
+
*/
|
|
259
|
+
declare const IssueBoard: ({ model, viewer, loadedAt, scope, showingAll, searchError, width, height, onOpen, onToggleAll, onRefresh, onSearch, onClearSearch, frame, }: IssueBoardProps) => string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | react.ReactPortal | react.ReactElement<unknown, string | react.JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | react.JSX.Element | null | undefined;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* An invented board for screenshots and tests — every key, name and host is
|
|
263
|
+
* made up. It carries what the contract must keep exercising: a container
|
|
264
|
+
* that is itself in a tab with a child under it, a parent that is not, an
|
|
265
|
+
* orphan, all three categories, and both ends of priority.
|
|
266
|
+
*/
|
|
267
|
+
declare const MOCK_BOARD_ROWS: BoardRow[];
|
|
268
|
+
declare const mockBoard: () => BoardModel;
|
|
269
|
+
|
|
270
|
+
export { type Block, type BoardModel, type BoardOptions, type BoardRow, type BoardScope, type BoardTab, type BoardTabs, IssueBoard, type IssueBoardProps, IssueBody, type IssueBodyProps, type IssueDetail, IssueDetailView, type IssueDetailViewProps, MOCK_BOARD_ROWS, OFF_BOARD, type StatusCategory, type TabSource, type Transition, blockIndexOfIssue, blocksFor, boardOf, countsFor, issueDetailOf, mockBoard, pillVariantFor, priorityGlyph, relativeAge, tabOf, tabsFromBoard, tabsFromCategories, tabsFromConfig, toBoardRow, transitionsOf, visibleTabs };
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { MarkdownViewport } from '@kud/ink-markdown';
|
|
2
|
-
import { useTabs, KeyValue, Badge, Tabs, FooterHints, StatusMessage, LoadingScreen } from '@kud/ink-ui';
|
|
2
|
+
import { useTabs, KeyValue, Badge, Tabs, FooterHints, StatusMessage, useListCursor, pillWidth, colors, TextInput, SelectableRow, Pill, LoadingScreen } from '@kud/ink-ui';
|
|
3
3
|
import { useInput, Box, Text, useStdout } from 'ink';
|
|
4
|
-
import { useState, useEffect } from 'react';
|
|
5
|
-
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
4
|
+
import { useState, useEffect, useMemo } from 'react';
|
|
5
|
+
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
6
6
|
import { exec } from 'child_process';
|
|
7
|
-
import { locateAttachments, adfToMarkdown } from '@kud/jira';
|
|
7
|
+
import { locateAttachments, adfToMarkdown, isContainerType, looksLikeJql } from '@kud/jira';
|
|
8
8
|
|
|
9
9
|
// src/components/issue-detail-view.tsx
|
|
10
10
|
var truncate = (s, max) => s.length <= max ? s : `${s.slice(0, Math.max(0, max - 1))}\u2026`;
|
|
@@ -61,7 +61,8 @@ var IssueDetailView = ({
|
|
|
61
61
|
if (key.upArrow || input === "k") setScroll((s) => Math.max(0, s - 1));
|
|
62
62
|
});
|
|
63
63
|
const source = active === "comments" ? commentsAsMarkdown(issue) : active === "attachments" ? attachmentsAsMarkdown(issue) : issue.description || "_No description._";
|
|
64
|
-
const
|
|
64
|
+
const keyValueRows = 3 + (issue.parent ? 1 : 0) + (issue.labels.length > 0 ? 1 : 0) + (frame ? 0 : 1);
|
|
65
|
+
const chromeLines = keyValueRows + 1 + 2 + 1 + (frame ? 0 : 4);
|
|
65
66
|
const bodyHeight = Math.max(3, height - chromeLines);
|
|
66
67
|
const hints = [
|
|
67
68
|
["\u2190\u2192", "section"],
|
|
@@ -88,7 +89,7 @@ var IssueDetailView = ({
|
|
|
88
89
|
issue.labels.length > 0 && /* @__PURE__ */ jsx(KeyValue, { label: "labels", value: issue.labels.join(", ") })
|
|
89
90
|
] }),
|
|
90
91
|
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Tabs, { items: tabs, active: active ?? "description" }) }),
|
|
91
|
-
/* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(
|
|
92
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, height: bodyHeight, overflow: "hidden", children: /* @__PURE__ */ jsx(
|
|
92
93
|
MarkdownViewport,
|
|
93
94
|
{
|
|
94
95
|
source,
|
|
@@ -223,5 +224,481 @@ var IssueBody = ({
|
|
|
223
224
|
}
|
|
224
225
|
);
|
|
225
226
|
};
|
|
227
|
+
var CATEGORIES = ["new", "indeterminate", "done"];
|
|
228
|
+
var categoryOf = (key) => CATEGORIES.find((c) => c === key) ?? "unknown";
|
|
229
|
+
var toBoardRow = (issue) => ({
|
|
230
|
+
key: issue.key,
|
|
231
|
+
summary: issue.fields.summary ?? "",
|
|
232
|
+
status: issue.fields.status?.name ?? "\u2014",
|
|
233
|
+
statusId: issue.fields.status?.id ?? "",
|
|
234
|
+
category: categoryOf(issue.fields.status?.statusCategory?.key),
|
|
235
|
+
type: issue.fields.issuetype?.name ?? "",
|
|
236
|
+
container: isContainerType(issue.fields.issuetype),
|
|
237
|
+
priority: issue.fields.priority?.name ?? null,
|
|
238
|
+
...issue.fields.parent ? {
|
|
239
|
+
parent: {
|
|
240
|
+
key: issue.fields.parent.key,
|
|
241
|
+
summary: issue.fields.parent.fields?.summary ?? ""
|
|
242
|
+
}
|
|
243
|
+
} : {},
|
|
244
|
+
assignee: issue.fields.assignee?.displayName ?? "unassigned",
|
|
245
|
+
updated: issue.fields.updated ?? ""
|
|
246
|
+
});
|
|
247
|
+
var OFF_BOARD = "__off_board__";
|
|
248
|
+
var CATEGORY_TABS = [
|
|
249
|
+
{ value: "todo", label: "To do", category: "new" },
|
|
250
|
+
{ value: "doing", label: "In progress", category: "indeterminate" },
|
|
251
|
+
{ value: "done", label: "Done", category: "done" }
|
|
252
|
+
];
|
|
253
|
+
var withOffBoard = (tabs) => [
|
|
254
|
+
...tabs,
|
|
255
|
+
{ value: OFF_BOARD, label: "Off board", offBoard: true }
|
|
256
|
+
];
|
|
257
|
+
var tabsFromConfig = (configured) => ({
|
|
258
|
+
source: "config",
|
|
259
|
+
tabs: withOffBoard(
|
|
260
|
+
configured.map((t, i) => ({
|
|
261
|
+
value: `config:${i}`,
|
|
262
|
+
label: t.label,
|
|
263
|
+
statuses: new Set(t.statuses.map((s) => s.toLowerCase()))
|
|
264
|
+
}))
|
|
265
|
+
)
|
|
266
|
+
});
|
|
267
|
+
var tabsFromBoard = (configuration) => {
|
|
268
|
+
const columns = configuration.columnConfig?.columns ?? [];
|
|
269
|
+
if (columns.length === 0) return null;
|
|
270
|
+
return {
|
|
271
|
+
source: "board",
|
|
272
|
+
tabs: withOffBoard(
|
|
273
|
+
columns.map((c, i) => ({
|
|
274
|
+
value: `column:${i}`,
|
|
275
|
+
label: c.name,
|
|
276
|
+
statuses: new Set(c.statuses.map((s) => s.id))
|
|
277
|
+
}))
|
|
278
|
+
)
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
var tabsFromCategories = () => ({
|
|
282
|
+
source: "categories",
|
|
283
|
+
tabs: CATEGORY_TABS
|
|
284
|
+
});
|
|
285
|
+
var tabOf = (row, { tabs }) => {
|
|
286
|
+
const claimed = tabs.find(
|
|
287
|
+
(t) => t.statuses?.has(row.statusId) || t.statuses?.has(row.status.toLowerCase())
|
|
288
|
+
);
|
|
289
|
+
if (claimed) return claimed.value;
|
|
290
|
+
const byCategory = tabs.find((t) => t.category && t.category === row.category);
|
|
291
|
+
if (byCategory) return byCategory.value;
|
|
292
|
+
return tabs.find((t) => t.offBoard)?.value ?? "doing";
|
|
293
|
+
};
|
|
294
|
+
var countsFor = (rows, tabs) => {
|
|
295
|
+
const counts = Object.fromEntries(
|
|
296
|
+
tabs.tabs.map((t) => [t.value, 0])
|
|
297
|
+
);
|
|
298
|
+
for (const row of rows)
|
|
299
|
+
counts[tabOf(row, tabs)] = (counts[tabOf(row, tabs)] ?? 0) + 1;
|
|
300
|
+
return counts;
|
|
301
|
+
};
|
|
302
|
+
var visibleTabs = (tabs, counts) => tabs.tabs.filter((t) => !t.offBoard || (counts[t.value] ?? 0) > 0);
|
|
303
|
+
var blocksFor = (rows) => {
|
|
304
|
+
const hasStructure = rows.some((r) => r.parent || r.container);
|
|
305
|
+
if (!hasStructure)
|
|
306
|
+
return rows.map((row) => ({ kind: "issue", row, depth: 0 }));
|
|
307
|
+
const heads = new Map(rows.filter((r) => r.container).map((r) => [r.key, r]));
|
|
308
|
+
const children = /* @__PURE__ */ new Map();
|
|
309
|
+
const orphans = [];
|
|
310
|
+
for (const row of rows) {
|
|
311
|
+
if (row.container) continue;
|
|
312
|
+
const key = row.parent?.key;
|
|
313
|
+
if (!key) {
|
|
314
|
+
orphans.push(row);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
children.set(key, [...children.get(key) ?? [], row]);
|
|
318
|
+
}
|
|
319
|
+
const groups = [];
|
|
320
|
+
for (const [key, head] of heads)
|
|
321
|
+
groups.push([
|
|
322
|
+
{ kind: "issue", row: head, depth: 0 },
|
|
323
|
+
...(children.get(key) ?? []).map((row) => ({
|
|
324
|
+
kind: "issue",
|
|
325
|
+
row,
|
|
326
|
+
depth: 1
|
|
327
|
+
}))
|
|
328
|
+
]);
|
|
329
|
+
for (const [key, kids] of children) {
|
|
330
|
+
if (heads.has(key)) continue;
|
|
331
|
+
groups.push([
|
|
332
|
+
{ kind: "fence", key, summary: kids[0].parent?.summary ?? "" },
|
|
333
|
+
...kids.map((row) => ({ kind: "issue", row, depth: 1 }))
|
|
334
|
+
]);
|
|
335
|
+
}
|
|
336
|
+
if (orphans.length)
|
|
337
|
+
groups.push([
|
|
338
|
+
...groups.length ? [{ kind: "fence", key: null, summary: "No epic" }] : [],
|
|
339
|
+
...orphans.map((row) => ({ kind: "issue", row, depth: 0 }))
|
|
340
|
+
]);
|
|
341
|
+
return groups.flatMap(
|
|
342
|
+
(group, i) => i === 0 ? group : [{ kind: "gap" }, ...group]
|
|
343
|
+
);
|
|
344
|
+
};
|
|
345
|
+
var blockIndexOfIssue = (blocks, issueIndex) => {
|
|
346
|
+
let seen = -1;
|
|
347
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
348
|
+
if (blocks[i].kind === "issue" && ++seen === issueIndex) return i;
|
|
349
|
+
}
|
|
350
|
+
return 0;
|
|
351
|
+
};
|
|
352
|
+
var priorityGlyph = (name) => {
|
|
353
|
+
const n = (name ?? "").trim().toLowerCase();
|
|
354
|
+
if (/highest|high|critical|blocker|urgent|^p[01]$/.test(n)) return "\u25B2";
|
|
355
|
+
if (/lowest|low|minor|trivial|^p[3-5]$/.test(n)) return "\u25BC";
|
|
356
|
+
return " ";
|
|
357
|
+
};
|
|
358
|
+
var UNITS = [
|
|
359
|
+
[60, "m"],
|
|
360
|
+
[60, "h"],
|
|
361
|
+
[24, "d"],
|
|
362
|
+
[7, "w"]
|
|
363
|
+
];
|
|
364
|
+
var relativeAge = (iso, now = Date.now()) => {
|
|
365
|
+
const then = Date.parse(iso);
|
|
366
|
+
if (Number.isNaN(then)) return "";
|
|
367
|
+
let value = Math.max(0, Math.round((now - then) / 1e3));
|
|
368
|
+
let unit = "s";
|
|
369
|
+
for (const [size, next] of UNITS) {
|
|
370
|
+
if (value < size) break;
|
|
371
|
+
value = Math.round(value / size);
|
|
372
|
+
unit = next;
|
|
373
|
+
}
|
|
374
|
+
return `${value}${unit}`;
|
|
375
|
+
};
|
|
376
|
+
var pillVariantFor = (row) => {
|
|
377
|
+
if (row.container) return "group";
|
|
378
|
+
const t = row.type.toLowerCase();
|
|
379
|
+
if (t === "bug") return "error";
|
|
380
|
+
if (t === "story") return "info";
|
|
381
|
+
return "muted";
|
|
382
|
+
};
|
|
383
|
+
var boardOf = async (client, jql, { tabs, board, limit = 200 } = {}) => {
|
|
384
|
+
const [issues, derived] = await Promise.all([
|
|
385
|
+
client.searchIssues(jql, { limit }),
|
|
386
|
+
tabs?.length ? Promise.resolve(tabsFromConfig(tabs)) : board !== void 0 ? client.getBoardConfiguration(board).then(tabsFromBoard) : Promise.resolve(null)
|
|
387
|
+
]);
|
|
388
|
+
return { rows: issues.map(toBoardRow), tabs: derived ?? tabsFromCategories() };
|
|
389
|
+
};
|
|
390
|
+
var CHROME = 6;
|
|
391
|
+
var LEGEND = [
|
|
392
|
+
["\u25B2", "high priority"],
|
|
393
|
+
["\u25BC", "low priority"],
|
|
394
|
+
["\u2500\u2500 epic \u2500\u2500", "a parent not in this tab; its rows hang beneath"],
|
|
395
|
+
["\u2514\u2500", "a row under the container above it"],
|
|
396
|
+
["story \xB7 bug \xB7 task", "issue type"],
|
|
397
|
+
["2d", "time since last update"]
|
|
398
|
+
];
|
|
399
|
+
var windowFor = (focus, total, size) => {
|
|
400
|
+
const start = Math.max(
|
|
401
|
+
0,
|
|
402
|
+
Math.min(focus - Math.floor(size / 2), total - size)
|
|
403
|
+
);
|
|
404
|
+
return { start, end: start + size };
|
|
405
|
+
};
|
|
406
|
+
var modeOf = (s) => s.mode === "auto" ? looksLikeJql(s.draft) ? "jql" : "text" : s.mode;
|
|
407
|
+
var rule = (label, width) => {
|
|
408
|
+
const head = `\u2500\u2500 ${label} `;
|
|
409
|
+
return head + "\u2500".repeat(Math.max(0, width - [...head].length));
|
|
410
|
+
};
|
|
411
|
+
var tabLegendLine = (model) => model.tabs.source === "categories" ? [
|
|
412
|
+
"To do \xB7 In progress \xB7 Done",
|
|
413
|
+
"status category \u2014 not any one board's column"
|
|
414
|
+
] : model.tabs.source === "board" ? ["tabs", "this board's own columns, matched by status id"] : ["tabs", "your config file's tabs"];
|
|
415
|
+
var IssueBoard = ({
|
|
416
|
+
model,
|
|
417
|
+
viewer,
|
|
418
|
+
loadedAt,
|
|
419
|
+
scope,
|
|
420
|
+
showingAll,
|
|
421
|
+
searchError,
|
|
422
|
+
width,
|
|
423
|
+
height,
|
|
424
|
+
onOpen,
|
|
425
|
+
onToggleAll,
|
|
426
|
+
onRefresh,
|
|
427
|
+
onSearch,
|
|
428
|
+
onClearSearch,
|
|
429
|
+
frame
|
|
430
|
+
}) => {
|
|
431
|
+
const [legend, setLegend] = useState(false);
|
|
432
|
+
const [search, setSearch] = useState({
|
|
433
|
+
open: false,
|
|
434
|
+
draft: "",
|
|
435
|
+
mode: "auto"
|
|
436
|
+
});
|
|
437
|
+
const [now, setNow] = useState(Date.now);
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
const tick = setInterval(() => setNow(Date.now()), 3e4);
|
|
440
|
+
return () => clearInterval(tick);
|
|
441
|
+
}, []);
|
|
442
|
+
const narrowed = useMemo(() => {
|
|
443
|
+
if (!search.open || modeOf(search) === "jql" || !search.draft.trim())
|
|
444
|
+
return model.rows;
|
|
445
|
+
const words = search.draft.trim().toLowerCase();
|
|
446
|
+
return model.rows.filter(
|
|
447
|
+
(r) => `${r.key} ${r.summary}`.toLowerCase().includes(words)
|
|
448
|
+
);
|
|
449
|
+
}, [model.rows, search]);
|
|
450
|
+
const counts = countsFor(narrowed, model.tabs);
|
|
451
|
+
const shown = visibleTabs(model.tabs, counts);
|
|
452
|
+
const tabItems = shown.map((t) => ({
|
|
453
|
+
value: t.value,
|
|
454
|
+
label: t.label,
|
|
455
|
+
count: counts[t.value] ?? 0
|
|
456
|
+
}));
|
|
457
|
+
const listFocused = !search.open && !legend;
|
|
458
|
+
const initial = shown.find((t) => t.category === "indeterminate")?.value ?? shown[0]?.value;
|
|
459
|
+
const { active, setActive } = useTabs(tabItems, {
|
|
460
|
+
initial,
|
|
461
|
+
isActive: listFocused
|
|
462
|
+
});
|
|
463
|
+
const tab = active ?? initial ?? "";
|
|
464
|
+
const blocks = useMemo(
|
|
465
|
+
() => blocksFor(narrowed.filter((r) => tabOf(r, model.tabs) === tab)),
|
|
466
|
+
[narrowed, model.tabs, tab]
|
|
467
|
+
);
|
|
468
|
+
const issues = blocks.filter(
|
|
469
|
+
(b) => b.kind === "issue"
|
|
470
|
+
);
|
|
471
|
+
const { cursor, setCursor } = useListCursor(issues.length, {
|
|
472
|
+
vimKeys: true,
|
|
473
|
+
isActive: listFocused
|
|
474
|
+
});
|
|
475
|
+
useEffect(() => setCursor(0), [tab, setCursor]);
|
|
476
|
+
useInput((input, key) => {
|
|
477
|
+
if (search.open) {
|
|
478
|
+
if (key.escape) setSearch((s) => ({ ...s, open: false }));
|
|
479
|
+
if (key.tab)
|
|
480
|
+
setSearch((s) => ({
|
|
481
|
+
...s,
|
|
482
|
+
mode: modeOf(s) === "jql" ? "text" : "jql"
|
|
483
|
+
}));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (legend) {
|
|
487
|
+
if (input === "?" || key.escape) setLegend(false);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (key.return && issues[cursor]) onOpen(issues[cursor].row.key);
|
|
491
|
+
if ((key.leftArrow || key.rightArrow) && tabItems.length) {
|
|
492
|
+
const at = tabItems.findIndex((t) => t.value === tab);
|
|
493
|
+
const next = (at + (key.leftArrow ? -1 : 1) + tabItems.length) % tabItems.length;
|
|
494
|
+
setActive(tabItems[next].value);
|
|
495
|
+
}
|
|
496
|
+
if (input === "/")
|
|
497
|
+
setSearch((s) => ({
|
|
498
|
+
...s,
|
|
499
|
+
open: true,
|
|
500
|
+
draft: scope.kind === "search" ? scope.query : s.draft
|
|
501
|
+
}));
|
|
502
|
+
if (input === "?") setLegend(true);
|
|
503
|
+
if (input === "a") onToggleAll();
|
|
504
|
+
if (input === "r") onRefresh();
|
|
505
|
+
if (input === "x" && scope.kind === "search") onClearSearch();
|
|
506
|
+
});
|
|
507
|
+
const searching = search.open || scope.kind === "search";
|
|
508
|
+
const chrome = CHROME + (searching ? 1 : 0) + (searchError ? 1 : 0);
|
|
509
|
+
const size = Math.max(3, height - chrome);
|
|
510
|
+
const focus = blockIndexOfIssue(blocks, cursor);
|
|
511
|
+
const { start, end } = windowFor(focus, blocks.length, size);
|
|
512
|
+
const keyWidth = Math.max(8, ...narrowed.map((r) => r.key.length));
|
|
513
|
+
const typeWidth = Math.max(
|
|
514
|
+
0,
|
|
515
|
+
...narrowed.map((r) => pillWidth(r.type.toLowerCase()))
|
|
516
|
+
);
|
|
517
|
+
const scopeLabel = scope.kind === "mine" ? `@${viewer}` : scope.mode === "jql" ? "jql" : `\u201C${scope.query}\u201D`;
|
|
518
|
+
const countLabel = search.open && narrowed.length !== model.rows.length ? `${narrowed.length} of ${model.rows.length}` : `${model.rows.length} ${model.rows.length === 1 ? "item" : "items"}`;
|
|
519
|
+
const facts = `${countLabel} \xB7 ${scopeLabel} \xB7 updated ${relativeAge(new Date(loadedAt).toISOString(), now)} ago`;
|
|
520
|
+
const hints = search.open ? [
|
|
521
|
+
["enter", "run"],
|
|
522
|
+
["\u21E5", modeOf(search) === "jql" ? "as plain words" : "as jql"],
|
|
523
|
+
["esc", "cancel"]
|
|
524
|
+
] : legend ? [["?", "close"]] : [
|
|
525
|
+
["\u2191\u2193", "move"],
|
|
526
|
+
["\u2190\u2192", "tab"],
|
|
527
|
+
["enter", "open"],
|
|
528
|
+
["/", scope.kind === "search" ? "edit" : "search"],
|
|
529
|
+
...scope.kind === "search" ? [["x", "clear"]] : [["a", showingAll ? "recent only" : "everything"]],
|
|
530
|
+
["r", "refresh"],
|
|
531
|
+
["?", "legend"],
|
|
532
|
+
["q", "quit"]
|
|
533
|
+
];
|
|
534
|
+
const emptyHint = () => {
|
|
535
|
+
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
536
|
+
if (total === 0) {
|
|
537
|
+
if (scope.kind === "search")
|
|
538
|
+
return "Nothing matches \u2014 / to edit, x to clear.";
|
|
539
|
+
return showingAll ? "Nothing is assigned to you." : "No open issues assigned to you \u2014 press a to include everything.";
|
|
540
|
+
}
|
|
541
|
+
const elsewhere = shown.filter((t) => t.value !== tab && (counts[t.value] ?? 0) > 0).map((t) => `${t.label} (${counts[t.value]})`).join(", ");
|
|
542
|
+
const current = shown.find((t) => t.value === tab);
|
|
543
|
+
const closed = current?.category === "done" && !showingAll ? " Only the last 14 days of closed work is loaded \u2014 press a for everything." : "";
|
|
544
|
+
return `Nothing here \u2014 \u2190\u2192 to ${elsewhere}.${closed}`;
|
|
545
|
+
};
|
|
546
|
+
const body = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
547
|
+
search.open ? /* @__PURE__ */ jsxs(Box, { paddingRight: 1, children: [
|
|
548
|
+
/* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: colors.accent, children: " / " }) }),
|
|
549
|
+
/* @__PURE__ */ jsx(Box, { flexGrow: 1, children: /* @__PURE__ */ jsx(
|
|
550
|
+
TextInput,
|
|
551
|
+
{
|
|
552
|
+
defaultValue: search.draft,
|
|
553
|
+
placeholder: "words, or JQL",
|
|
554
|
+
onChange: (draft) => setSearch((s) => ({ ...s, draft })),
|
|
555
|
+
onSubmit: (query) => {
|
|
556
|
+
setSearch((s) => ({ ...s, open: false, draft: query }));
|
|
557
|
+
if (query.trim()) onSearch(query, search.mode);
|
|
558
|
+
else onClearSearch();
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
) }),
|
|
562
|
+
/* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: modeOf(search) === "jql" ? "JQL" : "plain" }) })
|
|
563
|
+
] }) : scope.kind === "search" ? /* @__PURE__ */ jsxs(Box, { paddingRight: 1, children: [
|
|
564
|
+
/* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: colors.accent, children: " / " }) }),
|
|
565
|
+
/* @__PURE__ */ jsx(Box, { flexGrow: 1, children: /* @__PURE__ */ jsx(Text, { wrap: "truncate-end", children: scope.query }) }),
|
|
566
|
+
/* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: scope.mode === "jql" ? "JQL" : "plain" }) })
|
|
567
|
+
] }) : null,
|
|
568
|
+
searchError ? /* @__PURE__ */ jsxs(Text, { color: colors.error, children: [
|
|
569
|
+
" \u2717 ",
|
|
570
|
+
searchError
|
|
571
|
+
] }) : null,
|
|
572
|
+
/* @__PURE__ */ jsx(Box, { paddingLeft: 2, marginTop: searching || searchError ? 0 : 1, children: /* @__PURE__ */ jsx(Tabs, { active: tab, items: tabItems }) }),
|
|
573
|
+
/* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: 1, height: size, paddingRight: 1, children: legend ? [...LEGEND, tabLegendLine(model)].map(([glyph, meaning]) => /* @__PURE__ */ jsxs(Box, { paddingLeft: 4, children: [
|
|
574
|
+
/* @__PURE__ */ jsx(Box, { width: 28, flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: colors.accent, children: glyph }) }),
|
|
575
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: meaning })
|
|
576
|
+
] }, glyph)) : issues.length === 0 ? /* @__PURE__ */ jsx(Box, { paddingLeft: 4, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: emptyHint() }) }) : blocks.slice(start, end).map(
|
|
577
|
+
(block, i) => block.kind === "gap" ? /* @__PURE__ */ jsx(Text, { children: " " }, `gap:${start + i}`) : block.kind === "fence" ? /* @__PURE__ */ jsx(Box, { paddingLeft: 4, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: rule(
|
|
578
|
+
block.key ? `${block.summary} \xB7 ${block.key}` : block.summary,
|
|
579
|
+
width - 7
|
|
580
|
+
) }) }, `f:${block.key ?? "none"}`) : /* @__PURE__ */ jsxs(SelectableRow, { active: start + i === focus, children: [
|
|
581
|
+
block.depth === 1 ? /* @__PURE__ */ jsx(Box, { flexShrink: 0, width: 3, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2514\u2500 " }) }) : null,
|
|
582
|
+
/* @__PURE__ */ jsx(Box, { flexShrink: 0, width: 2, children: /* @__PURE__ */ jsx(
|
|
583
|
+
Text,
|
|
584
|
+
{
|
|
585
|
+
color: priorityGlyph(block.row.priority) === "\u25B2" ? colors.warning : colors.muted,
|
|
586
|
+
children: priorityGlyph(block.row.priority)
|
|
587
|
+
}
|
|
588
|
+
) }),
|
|
589
|
+
/* @__PURE__ */ jsx(Box, { flexShrink: 0, width: keyWidth + 2, children: /* @__PURE__ */ jsx(Text, { color: colors.accent, bold: start + i === focus, children: block.row.key }) }),
|
|
590
|
+
/* @__PURE__ */ jsx(Box, { flexShrink: 0, width: typeWidth + 2, children: /* @__PURE__ */ jsx(Pill, { tone: "soft", variant: pillVariantFor(block.row), children: block.row.type.toLowerCase() }) }),
|
|
591
|
+
/* @__PURE__ */ jsx(Box, { flexGrow: 1, children: /* @__PURE__ */ jsx(Text, { wrap: "truncate-end", bold: start + i === focus, children: block.row.summary }) }),
|
|
592
|
+
/* @__PURE__ */ jsx(Box, { flexShrink: 0, width: 5, justifyContent: "flex-end", children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: relativeAge(block.row.updated, now) }) })
|
|
593
|
+
] }, block.row.key)
|
|
594
|
+
) }),
|
|
595
|
+
/* @__PURE__ */ jsx(Box, { marginTop: 1, paddingLeft: 2, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: issues.length ? `${cursor + 1}/${issues.length}` : " " }) })
|
|
596
|
+
] });
|
|
597
|
+
if (frame) return frame({ facts, hints, body });
|
|
598
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
599
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: facts }),
|
|
600
|
+
body,
|
|
601
|
+
/* @__PURE__ */ jsx(FooterHints, { hints })
|
|
602
|
+
] });
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// src/lib/board-mock.ts
|
|
606
|
+
var CHECKOUT = { key: "SHOP-300", summary: "Basket and checkout correctness" };
|
|
607
|
+
var STOREFRONT = { key: "SHOP-350", summary: "Storefront refresh" };
|
|
608
|
+
var MOCK_BOARD_ROWS = [
|
|
609
|
+
{
|
|
610
|
+
key: "SHOP-300",
|
|
611
|
+
summary: CHECKOUT.summary,
|
|
612
|
+
status: "To Do",
|
|
613
|
+
statusId: "10000",
|
|
614
|
+
category: "new",
|
|
615
|
+
type: "Epic",
|
|
616
|
+
container: true,
|
|
617
|
+
priority: "Medium",
|
|
618
|
+
assignee: "Ada Okafor",
|
|
619
|
+
updated: "2026-08-14T10:00:00.000Z"
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
key: "SHOP-412",
|
|
623
|
+
summary: "Checkout total ignores the discount on the last item",
|
|
624
|
+
status: "In Progress",
|
|
625
|
+
statusId: "10001",
|
|
626
|
+
category: "indeterminate",
|
|
627
|
+
type: "Bug",
|
|
628
|
+
container: false,
|
|
629
|
+
priority: "High",
|
|
630
|
+
parent: CHECKOUT,
|
|
631
|
+
assignee: "Ada Okafor",
|
|
632
|
+
updated: "2026-08-14T09:12:00.000Z"
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
key: "SHOP-408",
|
|
636
|
+
summary: "Add a dark theme to the storefront",
|
|
637
|
+
status: "In Review",
|
|
638
|
+
statusId: "10002",
|
|
639
|
+
category: "indeterminate",
|
|
640
|
+
type: "Story",
|
|
641
|
+
container: false,
|
|
642
|
+
priority: "Medium",
|
|
643
|
+
parent: STOREFRONT,
|
|
644
|
+
assignee: "Ada Okafor",
|
|
645
|
+
updated: "2026-08-13T16:40:00.000Z"
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
key: "SHOP-397",
|
|
649
|
+
summary: "Search returns stale results after a filter change",
|
|
650
|
+
status: "To Do",
|
|
651
|
+
statusId: "10000",
|
|
652
|
+
category: "new",
|
|
653
|
+
type: "Bug",
|
|
654
|
+
container: false,
|
|
655
|
+
priority: "Medium",
|
|
656
|
+
parent: CHECKOUT,
|
|
657
|
+
assignee: "Ada Okafor",
|
|
658
|
+
updated: "2026-08-11T08:00:00.000Z"
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
key: "SHOP-401",
|
|
662
|
+
summary: "Coupon field accepts whitespace-only codes",
|
|
663
|
+
status: "In Progress",
|
|
664
|
+
statusId: "10001",
|
|
665
|
+
category: "indeterminate",
|
|
666
|
+
type: "Bug",
|
|
667
|
+
container: false,
|
|
668
|
+
priority: "Low",
|
|
669
|
+
parent: CHECKOUT,
|
|
670
|
+
assignee: "Ada Okafor",
|
|
671
|
+
updated: "2026-08-12T11:30:00.000Z"
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
key: "PLAT-88",
|
|
675
|
+
summary: "Rotate the staging database credentials",
|
|
676
|
+
status: "Blocked",
|
|
677
|
+
statusId: "10005",
|
|
678
|
+
category: "indeterminate",
|
|
679
|
+
type: "Task",
|
|
680
|
+
container: false,
|
|
681
|
+
priority: "Highest",
|
|
682
|
+
assignee: "Ada Okafor",
|
|
683
|
+
updated: "2026-08-09T14:05:00.000Z"
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
key: "SHOP-390",
|
|
687
|
+
summary: "Show the VAT breakdown on the order summary",
|
|
688
|
+
status: "Done",
|
|
689
|
+
statusId: "10003",
|
|
690
|
+
category: "done",
|
|
691
|
+
type: "Story",
|
|
692
|
+
container: false,
|
|
693
|
+
priority: "Medium",
|
|
694
|
+
parent: CHECKOUT,
|
|
695
|
+
assignee: "Ada Okafor",
|
|
696
|
+
updated: "2026-08-08T10:00:00.000Z"
|
|
697
|
+
}
|
|
698
|
+
];
|
|
699
|
+
var mockBoard = () => ({
|
|
700
|
+
rows: MOCK_BOARD_ROWS,
|
|
701
|
+
tabs: tabsFromCategories()
|
|
702
|
+
});
|
|
226
703
|
|
|
227
|
-
export { IssueBody, IssueDetailView, issueDetailOf, transitionsOf };
|
|
704
|
+
export { IssueBoard, IssueBody, IssueDetailView, MOCK_BOARD_ROWS, OFF_BOARD, blockIndexOfIssue, blocksFor, boardOf, countsFor, issueDetailOf, mockBoard, pillVariantFor, priorityGlyph, relativeAge, tabOf, tabsFromBoard, tabsFromCategories, tabsFromConfig, toBoardRow, transitionsOf, visibleTabs };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kud/jira-ink",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Ink components for viewing Jira issues: the issue detail screen, shared by jira-cli's TUI and cockpit",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Ink components for viewing Jira issues: the board and the issue detail screen, shared by jira-cli's TUI and cockpit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
@@ -29,11 +29,12 @@
|
|
|
29
29
|
"react": ">=19"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@kud/ink-ui": "0.25.0",
|
|
33
32
|
"@kud/ink-markdown": "0.3.0",
|
|
34
|
-
"@kud/
|
|
33
|
+
"@kud/ink-ui": "0.25.0",
|
|
34
|
+
"@kud/jira": "0.4.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
|
+
"@kud/cli-testing": "0.1.0",
|
|
37
38
|
"@types/node": "26.5.0",
|
|
38
39
|
"@types/react": "19.2.18",
|
|
39
40
|
"ink": "7.1.1",
|