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
69 changes: 68 additions & 1 deletion apps/desktop/src/renderer/desktop/issue-board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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]}`;
Expand Down Expand Up @@ -341,3 +364,47 @@ export function IssueBoard({
</div>
);
}

/**
* 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 (
<div className="issue-board-reading">
<SkeletonRegion label={label}>
<div className="issue-board">
{ISSUE_STATUS_ORDER.map((status, at) => {
const shape = SKELETON_COLUMNS[at % SKELETON_COLUMNS.length] ?? SKELETON_COLUMNS[0];
return (
<div key={status} className="issue-column">
<div className="issue-column-head">
<Skeleton shape={SKELETON_SHAPE.CIRCLE} width="var(--luke-control-icon)" />
<Text as={TEXT_ELEMENT.SPAN} variant={TEXT_VARIANT.SECTION}>
<Skeleton width={shape.heading} />
</Text>
</div>
<div className="sidebar-tasks issue-column-cards">
{shape.cards.map((title, card) => (
// oxlint-disable-next-line react/no-array-index-key -- a placeholder's cards have nothing but their place.
<div key={card} className="issue-card-skeleton">
<span className="issue-card-skeleton-line">
<Skeleton width={SKELETON_IDENTIFIER} />
<Skeleton width={SKELETON_TIME} />
</span>
<Skeleton width={title} />
</div>
))}
</div>
</div>
);
})}
</div>
</SkeletonRegion>
</div>
);
}
51 changes: 51 additions & 0 deletions apps/desktop/src/renderer/desktop/issue-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
ConfirmDialog,
EllipsisIcon,
IconButton,
SKELETON_SHAPE,
Skeleton,
SkeletonRegion,
useConfirmDialog,
useMenu,
} from "@luke/ui";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -259,3 +272,41 @@ export function IssueDetail({
</section>
);
}

/**
* 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 (
<div className="desktop-document">
<div className="plan-document-scroll scroll-fade">
<SkeletonRegion label={label}>
<div className="plan-body">
<div className="markdown task-title">
<div className="markdown-heading task-title-text" data-level="1">
<Skeleton width={SKELETON_TITLE} />
</div>
</div>
<div className="issue-byline">
<Skeleton width={SKELETON_BYLINE} />
</div>
<div className="issue-properties">
{SKELETON_CHIPS.map((width) => (
<Skeleton key={width} shape={SKELETON_SHAPE.CONTROL} width={width} />
))}
</div>
<div>
{SKELETON_DESCRIPTION.map((width) => (
<Skeleton key={width} width={width} />
))}
</div>
</div>
</SkeletonRegion>
</div>
</div>
);
}
24 changes: 22 additions & 2 deletions apps/desktop/src/renderer/desktop/issues-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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"]]);
});

Expand Down
29 changes: 18 additions & 11 deletions apps/desktop/src/renderer/desktop/issues-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.";
Expand Down Expand Up @@ -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<typeof useIssues>["read"], onBoard: boolean): CollectionState {
if (read === undefined) return onBoard ? COLLECTION_STATE.READY : COLLECTION_STATE.LOADING;
Expand Down Expand Up @@ -236,7 +238,10 @@ function ListPage({
</ErrorState>
}
>
{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 ? (
<IssueBoardSkeleton label={READING_LINE} />
) : onBoard ? (
<IssueBoard
issues={listed}
workspaceName={workspaceName}
Expand Down Expand Up @@ -306,7 +311,7 @@ function ViewOptions({
);
}

/** One issue's page while its issue is not drawn: its empty frame while it is read, or why it could not be, with Retry. */
/** One issue's page while its issue is not drawn: its page in blocks while it is read, or why it could not be, with Retry. */
function DetailFrame({
failure,
onRetry,
Expand All @@ -325,13 +330,15 @@ function DetailFrame({
<ExperimentTag />
</div>
</header>
<div className="automations-scroll scroll-fade-top">
{failure === undefined ? null : (
{failure === undefined ? (
<IssueDetailSkeleton label={READING_ONE_LINE} />
) : (
<div className="automations-scroll scroll-fade-top">
<ErrorState onRetry={onRetry} placement={ERROR_STATE_PLACEMENT.CENTRED}>
{failure === ISSUE_CALL_FAILURE.NOT_FOUND ? GONE_LINE : UNREAD_ONE_LINE}
</ErrorState>
)}
</div>
</div>
)}
</section>
);
}
Expand Down
35 changes: 35 additions & 0 deletions apps/desktop/src/renderer/styles/issues.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading