Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions apps/desktop/src/renderer/desktop/issues-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1430,23 +1430,49 @@ test("the page opens again in the layout last chosen, the list after List and th
assert.equal(thirdTime.querySelector(".home-section"), null);
});

test("a workspace with no issue yet still stands the board's columns, each saying it holds none", async () => {
/** The empty workspace's own New issue, under its line. */
function emptyStateNewIssue(page: HTMLElement): HTMLButtonElement | null {
return page.querySelector<HTMLButtonElement>(".luke-empty-state button");
}

test("a workspace with no issue yet says so on the board and in the list, its New issue opening the card, and the first issue filed stands the board's columns", async () => {
const page = mount();
await settle();
const boardSays = page.querySelector(".luke-empty-state")?.textContent;
const boardColumns = columns(page).length;
showList();
const listSays = page.querySelector(".luke-empty-state")?.textContent;
act(() => button("View options").click());
act(() => menuItem("Board").click());

const newIssue = emptyStateNewIssue(page);
assert.ok(newIssue, "the empty state offers New issue");
act(() => newIssue.click());
const field = composerField();
assert.ok(field, "the empty state's New issue opens the card");
const whileOpen = emptyStateNewIssue(page)?.disabled;
typeInto(field, "First issue");
act(() => {
field.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
await settle();

assert.equal(boardSays, "No issues yetNew issue");
assert.equal(boardColumns, 0, "no empty column stands on an empty workspace's board");
assert.equal(listSays, "No issues yetNew issue");
assert.equal(
whileOpen,
true,
"New issue is unavailable while the card stands, as the toolbar's is",
);
assert.equal(page.querySelector(".luke-empty-state"), null);
assert.deepEqual(columns(page), [
["Backlog", []],
["Backlog1", ["ACME-1"]],
["Todo", []],
["In progress", []],
["Done", []],
["Canceled", []],
]);
assert.equal(
[...page.querySelectorAll("[data-issue-column] p")].filter(
(idle) => idle.textContent === "No issues",
).length,
5,
);
});

test("once the page has come back from an issue, a layout chosen keeps the keyboard on View options rather than that issue's row", async () => {
Expand All @@ -1463,6 +1489,7 @@ test("once the page has come back from an issue, a layout chosen keeps the keybo
});

test("a column's + opens the new issue's card with that column's status chosen, the priority at its default, and the title holding the keyboard", async () => {
standing = [issue(1)];
mount();
await settle();

Expand Down
35 changes: 26 additions & 9 deletions apps/desktop/src/renderer/desktop/issues-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ import { useRowKeys } from "./use-row-keys";
* and its toolbar says so with a quiet badge and Send feedback. New issue,
* or C pressed anywhere on the page but a field, opens a card as New task
* does (`new-issue-dialog.tsx`), whose issue lands in its group at once; a
* board column's + opens it with that column's status chosen.
* board column's + opens it with that column's status chosen. A workspace
* with no issue yet says so in either layout, New issue its one way on.
* ↑ and ↓ move through the rows from anywhere on the
* page, and Return opens the one reached on its own page
* (`issue-detail.tsx`), from which Escape comes back to the list with that
Expand Down Expand Up @@ -90,6 +91,9 @@ const storedLayout = Schema.fromJsonString(Schema.Literals(Object.values(ISSUES_
/** What the toolbar's layout menu is called. */
const VIEW_OPTIONS = "View options";

/** What files a new issue is called, in the toolbar and in an empty workspace. */
const NEW_ISSUE = "New issue";

/** What the list's page is read as while its issues are, and one issue's page while its issue is. */
const READING_LINE = "Reading issues";
const READING_ONE_LINE = "Reading the issue";
Expand Down Expand Up @@ -142,16 +146,17 @@ function keyboardLeftWith(issueId: string): boolean {
}

/**
* What the list's page stands, by its read and its layout. Note that an
* empty board still stands its columns, each saying it holds none, so only
* the list says there are none yet; and that a board being read stands as
* ready, drawing its own columns of blocks (`IssueBoardSkeleton`), since
* the page's loading rows are a list's shape, not a board's.
* What the list's page stands, by its read and its layout. Note that a
* workspace with no issue yet stands the empty state in either layout, as
* Factory does, rather than a board of empty columns; and that
* a board being read stands as ready, drawing its own columns of blocks
* (`IssueBoardSkeleton`), since the page's loading rows are a list's shape,
* not a board's.
*/
function listState(read: ReturnType<typeof useIssues>["read"], onBoard: boolean): CollectionState {
if (read === undefined) return onBoard ? COLLECTION_STATE.READY : COLLECTION_STATE.LOADING;
if ("failure" in read) return COLLECTION_STATE.ERROR;
return !onBoard && read.issues.length === 0 ? COLLECTION_STATE.EMPTY : COLLECTION_STATE.READY;
return read.issues.length === 0 ? COLLECTION_STATE.EMPTY : COLLECTION_STATE.READY;
}

/** The list's page: the listed workspace's issues by status, a new one a press away. */
Expand Down Expand Up @@ -225,14 +230,26 @@ function ListPage({
disabled={onCompose === undefined}
onClick={onCompose}
>
New issue
{NEW_ISSUE}
</Button>
</div>
</header>
}
state={listState(read, onBoard)}
loadingLabel={READING_LINE}
empty={<EmptyState icon={<ListTodoIcon />}>No issues yet</EmptyState>}
empty={
// Note that its New issue is the toolbar's, unavailable while no workspace is listed or the card stands.
<EmptyState
icon={<ListTodoIcon />}
action={{
label: NEW_ISSUE,
disabled: onCompose === undefined,
onClick: () => onCompose?.(),
}}
>
No issues yet
</EmptyState>
}
error={
<ErrorState onRetry={reload} placement={ERROR_STATE_PLACEMENT.CENTRED}>
{UNREAD_LINE}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,12 @@
.luke-collection-page-notice::after {
flex: 3 1 0;
}

/* An EmptyState, a centred ErrorState among them, stands itself a little
above the middle of the room it fills, so the notice hands it the whole
room rather than a sixth of it, which clipped a way on under its line. */
.luke-collection-page-notice:has(> .luke-empty-state)::before,
.luke-collection-page-notice:has(> .luke-empty-state)::after {
content: none;
}
}
18 changes: 16 additions & 2 deletions packages/ui/src/components/empty-state/empty-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import { EmptyState, type EmptyStateAction } from "./empty-state.js";
* empty-state.test.ts -- an empty place shows its heading and its line, its
* glyph hidden from a reader; its one way on is a button named by its words
* that answers a press; and a place with a line alone offers neither a
* heading nor a button; a place a failure left empty is one alert holding
* its line and its way on, and an empty one is no alert.
* heading nor a button; a way on unavailable for now stands and takes no
* press; a place a failure left empty is one alert holding its line and its
* way on, and an empty one is no alert.
*/

declare global {
Expand Down Expand Up @@ -60,6 +61,19 @@ test("an empty place shows its heading and line, and its way on answers a press"
expect(pressed).toEqual(["close"]);
});

test("a way on unavailable for now still stands, and a press does nothing", () => {
const pressed: string[] = [];
const container = draw({
action: { label: "New issue", disabled: true, onClick: () => pressed.push("new") },
children: "No issues yet",
});
const button = container.querySelector("button");
expect(button?.textContent).toBe("New issue");
expect(button?.disabled).toBe(true);
act(() => button?.click());
expect(pressed).toEqual([]);
});

test("the glyph says nothing to a reader", () => {
const container = draw({ title: "Not found", children: LINE });
expect(container.querySelector("svg")?.closest("[aria-hidden='true']")).not.toBeNull();
Expand Down
9 changes: 8 additions & 1 deletion packages/ui/src/components/empty-state/empty-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export interface EmptyStateAction {
/** The glyph before its words, where one says what the press does. */
readonly icon?: ReactNode;
readonly onClick: () => void;
/** Whether the press is unavailable for now, as the same act elsewhere on the page is. */
readonly disabled?: boolean | undefined;
}

/**
Expand Down Expand Up @@ -63,7 +65,12 @@ export function EmptyState({
<Text tone={TEXT_TONE.MUTED}>{children}</Text>
</div>
{action === undefined ? null : (
<Button variant={BUTTON_VARIANT.QUIET} icon={action.icon} onClick={action.onClick}>
<Button
variant={BUTTON_VARIANT.QUIET}
icon={action.icon}
disabled={action.disabled}
onClick={action.onClick}
>
{action.label}
</Button>
)}
Expand Down
Loading