diff --git a/apps/desktop/src/renderer/desktop/issue-board.tsx b/apps/desktop/src/renderer/desktop/issue-board.tsx index 521b89fba7..89239e4eff 100644 --- a/apps/desktop/src/renderer/desktop/issue-board.tsx +++ b/apps/desktop/src/renderer/desktop/issue-board.tsx @@ -5,7 +5,17 @@ import { issueIdentifier, } from "@luke/features/issue-list-model"; import { ISSUE_STATUS_ORDER, type Issue, type IssueStatus } from "@luke/features/issue-wire"; -import { IconButton, TEXT_ELEMENT, TEXT_TONE, TEXT_VARIANT, Text, useMenu } from "@luke/ui"; +import { + IconButton, + SKELETON_SHAPE, + Skeleton, + SkeletonRegion, + TEXT_ELEMENT, + TEXT_TONE, + TEXT_VARIANT, + Text, + useMenu, +} from "@luke/ui"; import { PlusIcon } from "lucide-react"; import { useEffect, useLayoutEffect, useRef } from "react"; import { RelativeTime } from "../tasks/relative-time"; @@ -41,6 +51,19 @@ import { useRowDrag } from "./use-row-drag"; /** What a column with no issue says in its own place. */ const NO_ISSUES = "No issues"; +/** How wide each placeholder column's heading stands, as a share of what its glyph leaves, and how wide each of its cards' titles, so the columns read as columns of their own. */ +const SKELETON_COLUMNS = [ + { heading: "56%", cards: ["82%", "64%", "74%"] }, + { heading: "36%", cards: ["70%", "88%"] }, + { heading: "48%", cards: ["76%", "58%", "84%"] }, + { heading: "34%", cards: ["66%"] }, + { heading: "52%", cards: ["72%"] }, +] as const; + +/** How wide a placeholder card's identifier and its time stand, as wide as "ACME-12" and a short "2h". */ +const SKELETON_IDENTIFIER = "48px"; +const SKELETON_TIME = "20px"; + /** What a column's + is called, by the column it files into. */ function newIssueIn(status: IssueStatus): string { return `New issue in ${ISSUE_STATUS_WORDS[status]}`; @@ -341,3 +364,47 @@ export function IssueBoard({ ); } + +/** + * The board while its issues are first read: a column for every status in + * the board's own box, its head a block where the glyph and the name will + * stand, over a few cards each a block for its identifier and time over one + * for its title, so the cards land where the blocks stood rather than on a + * blank page. Nothing stands for the counts and the +, and the region says + * what is being read. + */ +export function IssueBoardSkeleton({ label }: { label: string }): React.JSX.Element { + return ( +
+ +
+ {ISSUE_STATUS_ORDER.map((status, at) => { + const shape = SKELETON_COLUMNS[at % SKELETON_COLUMNS.length] ?? SKELETON_COLUMNS[0]; + return ( +
+
+ + + + +
+
+ {shape.cards.map((title, card) => ( + // oxlint-disable-next-line react/no-array-index-key -- a placeholder's cards have nothing but their place. +
+ + + + + +
+ ))} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/desktop/issue-detail.tsx b/apps/desktop/src/renderer/desktop/issue-detail.tsx index e6ae089aee..e12acde4a2 100644 --- a/apps/desktop/src/renderer/desktop/issue-detail.tsx +++ b/apps/desktop/src/renderer/desktop/issue-detail.tsx @@ -8,6 +8,9 @@ import { ConfirmDialog, EllipsisIcon, IconButton, + SKELETON_SHAPE, + Skeleton, + SkeletonRegion, useConfirmDialog, useMenu, } from "@luke/ui"; @@ -42,6 +45,16 @@ import type { IssueChange } from "./use-issues"; * which no longer holds the issue. */ +/** How wide the placeholder title and the line under it stand, as shares of their lines. */ +const SKELETON_TITLE = "55%"; +const SKELETON_BYLINE = "40%"; + +/** How wide the placeholder Status and Priority chips stand, as wide as a chip naming one. */ +const SKELETON_CHIPS = ["112px", "96px"] as const; + +/** How wide each placeholder line of the description stands, as a share of its line. */ +const SKELETON_DESCRIPTION = ["92%", "86%", "64%"] as const; + /** * The toolbar's path, as the automation editor's: the Issues list a press * away, and the issue's identifier where one stands. @@ -259,3 +272,41 @@ export function IssueDetail({ ); } + +/** + * One issue's page while its issue is first read, opened from a link or a + * notification before the list holds it: the page's own document, a block + * where the title will stand in its heading's type, one for the line under + * it, the two chips' boxes, and a few lines of description, so the issue + * lands where the blocks stood. The region says what is being read. + */ +export function IssueDetailSkeleton({ label }: { label: string }): React.JSX.Element { + return ( +
+
+ +
+
+
+ +
+
+
+ +
+
+ {SKELETON_CHIPS.map((width) => ( + + ))} +
+
+ {SKELETON_DESCRIPTION.map((width) => ( + + ))} +
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/desktop/issues-page.test.ts b/apps/desktop/src/renderer/desktop/issues-page.test.ts index b3932c46bc..b0c46eb6b4 100644 --- a/apps/desktop/src/renderer/desktop/issues-page.test.ts +++ b/apps/desktop/src/renderer/desktop/issues-page.test.ts @@ -839,6 +839,21 @@ test("an issue's page opened on an issue the service does not hand the reader, a assert.match(page.textContent ?? "", /This issue is gone, or not in this workspace\./u); }); +test("an issue's page opened before its issue is read, as a link opens it, stands the issue's page in blocks, said as reading, until the issue lands in their place", async () => { + standing = [issue(1, { title: "Fix login" })]; + const page = mount({ kind: ISSUES_PAGE.ISSUE, issueId: issue(1).id }); + const reading = page.querySelector('[role="status"]'); + const readingWords = reading?.textContent; + const readingTitle = titleOf(page); + await settle(); + + assert.equal(readingWords, "Reading the issue"); + assert.ok(reading?.closest(".desktop-document"), "the blocks stand in the issue's own document"); + assert.equal(readingTitle, undefined); + assert.equal(page.querySelector('[role="status"]'), null); + assert.equal(titleOf(page), "Fix login"); +}); + test("two changes at once draw the newest's answer, though the older one is answered after it", async () => { standing = [issue(1)]; const page = await openIssue(issue(1).id); @@ -1365,7 +1380,7 @@ test("View options lays the page out as the list and back as the board, and an i assert.equal(page.querySelector(".home-section"), null); }); -test("while the issues are read the list stands rows of blocks, said as reading, and the board stands nothing until its columns can", async () => { +test("while the issues are read the list stands rows of blocks and the board a column of blocks for every status, each said as reading", async () => { standing = [issue(1)]; window.localStorage.setItem("luke.issuesLayout", JSON.stringify("list")); const list = mount(); @@ -1378,12 +1393,17 @@ test("while the issues are read the list stands rows of blocks, said as reading, window.localStorage.setItem("luke.issuesLayout", JSON.stringify("board")); const board = mount(); const boardReading = board.querySelector('[role="status"]'); + const readingColumns = boardReading?.querySelectorAll(".issue-column").length; + const readingCards = boardReading?.querySelectorAll("[data-issue-id]").length; await settle(); assert.equal(reading, "Reading issues"); assert.equal(read, null); assert.deepEqual(listedGroups, [["Backlog1", ["ACME-1 Issue 1"]]]); - assert.equal(boardReading, null); + assert.equal(boardReading?.textContent, "Reading issues"); + assert.equal(readingColumns, 5, "a column for every status stands while the board is read"); + assert.equal(readingCards, 0, "no issue's card stands before the issues are read"); + assert.equal(board.querySelector('[role="status"]'), null); assert.deepEqual(columns(board)[0], ["Backlog1", ["ACME-1"]]); }); diff --git a/apps/desktop/src/renderer/desktop/issues-page.tsx b/apps/desktop/src/renderer/desktop/issues-page.tsx index 3e4dc103c6..569229bab5 100644 --- a/apps/desktop/src/renderer/desktop/issues-page.tsx +++ b/apps/desktop/src/renderer/desktop/issues-page.tsx @@ -39,8 +39,8 @@ import { TOAST_HOLD, TOAST_TONE, useToast } from "../toast"; import { commandKeyshortcuts, Tooltip } from "../tooltip"; import { ActionMenu, type MenuAction } from "./action-menu"; import { readKept, writeKept } from "./folded-groups"; -import { IssueBoard, useBoardKeys } from "./issue-board"; -import { IssueCrumbs, IssueDetail } from "./issue-detail"; +import { IssueBoard, IssueBoardSkeleton, useBoardKeys } from "./issue-board"; +import { IssueCrumbs, IssueDetail, IssueDetailSkeleton } from "./issue-detail"; import { IssueList, listRowIssue } from "./issue-list"; import { type IssueDraft, NewIssueDialog } from "./new-issue-dialog"; import { type IssueChange, useIssue, useIssues } from "./use-issues"; @@ -89,8 +89,9 @@ const storedLayout = Schema.fromJsonString(Schema.Literals(Object.values(ISSUES_ /** What the toolbar's layout menu is called. */ const VIEW_OPTIONS = "View options"; -/** What the list's page is read as while its issues are. */ +/** 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"; /** What the list says where it could not be read. */ const UNREAD_LINE = "The issues could not be read."; @@ -142,8 +143,9 @@ 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 - * nothing, since the page's loading rows are a list's shape, not a board's. + * 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. */ function listState(read: ReturnType["read"], onBoard: boolean): CollectionState { if (read === undefined) return onBoard ? COLLECTION_STATE.READY : COLLECTION_STATE.LOADING; @@ -236,7 +238,10 @@ function ListPage({ } > - {listed === undefined ? null : onBoard ? ( + {/* Note that the children stand only once ready, which a page still being read is only on the board. */} + {listed === undefined ? ( + + ) : onBoard ? ( -
- {failure === undefined ? null : ( + {failure === undefined ? ( + + ) : ( +
{failure === ISSUE_CALL_FAILURE.NOT_FOUND ? GONE_LINE : UNREAD_ONE_LINE} - )} -
+
+ )} ); } diff --git a/apps/desktop/src/renderer/styles/issues.css b/apps/desktop/src/renderer/styles/issues.css index 86baa3481f..b5b8847dec 100644 --- a/apps/desktop/src/renderer/styles/issues.css +++ b/apps/desktop/src/renderer/styles/issues.css @@ -288,3 +288,38 @@ .issue-board .sidebar-task-time { margin-left: auto; } + +/* The board while its issues are read (`IssueBoardSkeleton`): the reading + region and the box around it hold to the page's height as the board + does, so its columns stand as tall as the board's will. */ +.issue-board-reading, +.issue-board-reading > [role="status"] { + display: flex; + min-width: min-content; + min-height: 0; + flex: 1 1 0; + flex-direction: column; +} + +/* A placeholder column's name takes what its glyph's block leaves, as the + name does. */ +.issue-column-head > .luke-skeleton + .luke-text { + flex: 1; +} + +/* A placeholder card stands a card's room: its identifier and time on a + caption's line over its title on the body's, at the card's own inset. */ +.issue-card-skeleton { + display: flex; + flex-direction: column; + gap: var(--luke-space-2); + padding: var(--luke-space-8) var(--row-inset); + font-size: var(--luke-type-body-size); +} + +.issue-card-skeleton-line { + display: flex; + justify-content: space-between; + gap: var(--luke-space-8); + font-size: var(--luke-type-caption-size); +}