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
24 changes: 13 additions & 11 deletions apps/desktop/src/renderer/desktop/issue-list.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ISSUE_STATUS_WORDS, issueGroups, issueIdentifier } from "@luke/features/issue-list-model";
import {
type IssueGrouping,
issueIdentifier,
issueListGroups,
} from "@luke/features/issue-list-model";
import type { Issue } from "@luke/features/issue-wire";
import { List, ListRow, TEXT_ELEMENT, TEXT_TONE, TEXT_VARIANT, Text, useMenu } from "@luke/ui";
import { useCallback, useLayoutEffect, useRef } from "react";
Expand All @@ -8,7 +12,7 @@ import { PriorityMark, StatusGlyph } from "./issue-pickers";
import type { IssueChange } from "./use-issues";

/**
* issue-list.tsx -- a workspace's issues as the Issues page lists them: under a heading for each status, the work under way first, each row its status, its identifier, its title, how much it matters, and when it last changed.
* issue-list.tsx -- a workspace's issues as the Issues page lists them: under a heading for each status, the work under way first, or for each priority, each row its status, its identifier, its title, how much it matters, and when it last changed.
*
* The sections are Home's own (`desktop-home.tsx`): a heading with its
* count over a `List` of `ListRow`s, each a press, so the page's ↑ and ↓
Expand Down Expand Up @@ -91,9 +95,10 @@ function IssueRow({
);
}

/** The issues under a heading for each status they stand in, the work under way first. */
/** The issues under a heading for each status they stand in, the work under way first, or for each priority, the most pressing first. */
export function IssueList({
issues,
grouping,
workspaceName,
now,
focused,
Expand All @@ -102,6 +107,7 @@ export function IssueList({
onChange,
}: {
issues: readonly Issue[];
grouping: IssueGrouping;
workspaceName: string;
now: number;
/** The issue whose row takes the keyboard as the list is drawn: the one the page came back from. */
Expand All @@ -114,22 +120,18 @@ export function IssueList({
}): React.JSX.Element {
return (
<>
{issueGroups(issues).map((group) => (
<section
key={group.status}
className="home-section"
aria-label={ISSUE_STATUS_WORDS[group.status]}
>
{issueListGroups(issues, grouping).map((group) => (
<section key={group.key} className="home-section" aria-label={group.words}>
{/* Note that the count stands inside the heading, so a reader moving by headings hears it. */}
<h2 className="issue-group-head">
<Text as={TEXT_ELEMENT.SPAN} variant={TEXT_VARIANT.SECTION} tone={TEXT_TONE.MUTED}>
{ISSUE_STATUS_WORDS[group.status]}
{group.words}
</Text>
<Text as={TEXT_ELEMENT.SPAN} variant={TEXT_VARIANT.CAPTION} tone={TEXT_TONE.SUBTLE}>
{group.issues.length}
</Text>
</h2>
<List label={ISSUE_STATUS_WORDS[group.status]}>
<List label={group.words}>
{group.issues.map((issue) => (
<IssueRow
key={issue.id}
Expand Down
45 changes: 43 additions & 2 deletions apps/desktop/src/renderer/desktop/issues-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2117,13 +2117,13 @@ test("an issue's page lists its archived tasks too, and its tasks newest started
assert.deepEqual(workOf(page), ["Third try", "Second try", "First try"]);
});

/** The item of the menu standing open whose label, its glyph and any detail left out, is `name`. */
/** The item whose label, its glyph and any detail left out, is `name`, of the newest menu standing open that holds one: a submenu before the menu it hangs from. */
function labelledItem(name: string): HTMLButtonElement {
const found = [
...document.querySelectorAll<HTMLElement>(
'[role="menu"]:not([data-leaving]) .luke-menu-item-label',
),
].find((each) => each.textContent === name);
].findLast((each) => each.textContent === name);
const item = found?.closest<HTMLButtonElement>("button");
assert.ok(item, `no menu item labelled ${name}`);
return item;
Expand Down Expand Up @@ -2286,3 +2286,44 @@ test("a filter one account chose is not the filter of another account signed in
assert.deepEqual(groups(other), [["Backlog2", ["ACME-2 Issue 2", "ACME-1 Issue 1"]]]);
assert.equal(saysFiltered(other), false);
});

test("Group by Priority in the List's View options heads its issues by priority, the most pressing first, stands when the page opens again, and Status heads them by status again", async () => {
standing = [
issue(1, { priority: ISSUE_PRIORITY.LOW, status: ISSUE_STATUS.TODO }),
issue(2, { priority: ISSUE_PRIORITY.URGENT }),
issue(3, { priority: ISSUE_PRIORITY.LOW, status: ISSUE_STATUS.IN_PROGRESS }),
];
const page = mount();
await settle();
act(() => button("View options").click());
const offeredOnBoard = [...document.querySelectorAll(".luke-menu-item-label")].some(
(label) => label.textContent === "Group by",
);
act(() => button("View options").click());
await settle();
showList();

chooseView("Priority", "Group by");
const byPriority = groups(page);
act(() => root?.unmount());
const reopened = mount();
await settle();
const keptByPriority = groups(reopened);
chooseView("Status", "Group by");

assert.equal(
offeredOnBoard,
false,
"the board's columns are its statuses, so it offers no grouping",
);
assert.deepEqual(byPriority, [
["Urgent1", ["ACME-2 Issue 2"]],
["Low2", ["ACME-3 Issue 3", "ACME-1 Issue 1"]],
]);
assert.deepEqual(keptByPriority, byPriority);
assert.deepEqual(groups(reopened), [
["In progress1", ["ACME-3 Issue 3"]],
["Todo1", ["ACME-1 Issue 1"]],
["Backlog1", ["ACME-2 Issue 2"]],
]);
});
65 changes: 60 additions & 5 deletions apps/desktop/src/renderer/desktop/issues-page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import {
clearedIssueFilter,
filteredIssues,
ISSUE_GROUPING,
ISSUE_PRIORITY_WORDS,
type IssueGrouping,
issueFilterAnyPriority,
issueFilterChanged,
issueFilterCreatedBy,
Expand Down Expand Up @@ -34,6 +36,7 @@ import {
import { Schema } from "effect";
import {
CircleCheckIcon,
CircleDotIcon,
ListFilterIcon,
ListIcon,
ListTodoIcon,
Expand Down Expand Up @@ -79,9 +82,10 @@ import { useRowKeys } from "./use-row-keys";
* ↑ 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
* row holding the keyboard. Board or List, as View options last chose, is
* kept in the renderer's own storage as the Inbox's view choices are
* (`folded-groups.ts`), so the page opens in it again on this Mac. View
* row holding the keyboard. Board or List, as View options last chose, and
* whether the List groups by status or by priority, are kept in the
* renderer's own storage as the Inbox's view choices are
* (`folded-groups.ts`), so the page opens in them again on this Mac. View
* options also filters the issues, by priority, by who filed them, and past
* settled ones gone quiet (`issue-list-model.ts`), kept per workspace the
* same way (`issue-filter-view.ts`); a filter that keeps none of a
Expand Down Expand Up @@ -110,6 +114,18 @@ const LAYOUT_KEY = "luke.issuesLayout";
/** The layout last chosen. */
const storedLayout = Schema.fromJsonString(Schema.Literals(Object.values(ISSUES_LAYOUT)));

/** Where the List's grouping last chosen is kept in the renderer's storage. */
const GROUPING_KEY = "luke.issuesGrouping";

/** The List's grouping last chosen. */
const storedGrouping = Schema.fromJsonString(Schema.Literals(Object.values(ISSUE_GROUPING)));

/** How the List groups its issues, and the press that changes it. */
interface GroupingControl {
readonly grouping: IssueGrouping;
readonly onGrouping: (grouping: IssueGrouping) => void;
}

/** What the toolbar's layout menu is called. */
const VIEW_OPTIONS = "View options";

Expand Down Expand Up @@ -197,6 +213,7 @@ function ListPage({
focused,
layout,
onLayout,
grouping,
filter,
onFocus,
onCompose,
Expand All @@ -210,6 +227,7 @@ function ListPage({
focused: string | undefined;
layout: IssuesLayout;
onLayout: (layout: IssuesLayout) => void;
grouping: GroupingControl;
/** What the page leaves out of the workspace's issues, and the press that changes it. */
filter: IssueFilterControl;
/** Hands the keyboard to an issue's card or row wherever it is drawn next, or to none. */
Expand Down Expand Up @@ -261,6 +279,7 @@ function ListPage({
workspace={workspace}
layout={layout}
onLayout={onLayout}
grouping={grouping}
filter={filter}
/>
<Button
Expand Down Expand Up @@ -325,6 +344,7 @@ function ListPage({
) : (
<div className="issue-groups">
<IssueList
grouping={grouping.grouping}
issues={listed}
workspaceName={workspaceName}
now={now}
Expand All @@ -342,7 +362,8 @@ function ListPage({
/**
* The toolbar's button that drops the menu of how the page lays its issues
* out and what it leaves out, each choice checked, as the sidebar's tasks
* View options does: Board or List; Priority, every one or those chosen;
* View options does: Board or List, and while the List stands, Group by
* Status or Priority; Priority, every one or those chosen;
* Created by, in a shared workspace or while it filters, Anyone, Me, or a
* member by face; and Show completed, whether a Done or Canceled issue
* last changed over a week ago stands, off at first. While the filter
Expand All @@ -354,13 +375,15 @@ function ViewOptions({
workspace,
layout,
onLayout,
grouping,
filter: { filter, onFilter },
}: {
shown: boolean;
/** The workspace listed, which alone filters by creator where it is shared. */
workspace: TasksControl["workspace"];
layout: IssuesLayout;
onLayout: (layout: IssuesLayout) => void;
grouping: GroupingControl;
filter: IssueFilterControl;
}): React.JSX.Element {
const menu = useMenu(shown);
Expand All @@ -381,6 +404,26 @@ function ViewOptions({
onSelect: () => onLayout(ISSUES_LAYOUT.LIST),
},
];
const groupBy = (label: string, icon: React.JSX.Element, value: IssueGrouping): MenuAction => ({
label,
icon,
checked: grouping.grouping === value,
onSelect: () => grouping.onGrouping(value),
});
// Note that only the List groups: the board's columns are its statuses.
const groupings: MenuEntry[] =
layout === ISSUES_LAYOUT.LIST
? [
{
label: "Group by",
icon: <ListIcon />,
items: [
groupBy("Status", <CircleDotIcon />, ISSUE_GROUPING.STATUS),
groupBy("Priority", <SignalHighIcon />, ISSUE_GROUPING.PRIORITY),
],
},
]
: [];
const priorities: MenuEntry = {
label: "Priority",
icon: <SignalHighIcon />,
Expand Down Expand Up @@ -436,7 +479,7 @@ function ViewOptions({
<ActionMenu
label={VIEW_OPTIONS}
menu={menu.drawn}
groups={[layouts, [priorities, ...createdBy], [completed]]}
groups={[[...layouts, ...groupings], [priorities, ...createdBy], [completed]]}
onClose={menu.close}
/>
)}
Expand Down Expand Up @@ -579,6 +622,9 @@ function IssuesPages({ tasks }: { tasks: TasksControl }): React.JSX.Element {
const [layout, setLayout] = useState<IssuesLayout>(() =>
readKept(LAYOUT_KEY, storedLayout, ISSUES_LAYOUT.BOARD),
);
const [grouping, setGrouping] = useState<IssueGrouping>(() =>
readKept(GROUPING_KEY, storedGrouping, ISSUE_GROUPING.STATUS),
);
const filter = useIssueFilter(tasks.me, workspace?.id);
const [pages, setPages] = useState<HTMLDivElement | null>(null);
const listedNow = useRef(workspace?.id);
Expand Down Expand Up @@ -629,6 +675,15 @@ function IssuesPages({ tasks }: { tasks: TasksControl }): React.JSX.Element {
setLayout(chosen);
writeKept(LAYOUT_KEY, storedLayout, chosen);
}}
grouping={{
grouping,
onGrouping: (chosen) => {
// As with a layout, the row the page came back from takes no keyboard from the menu.
setCameFrom(undefined);
setGrouping(chosen);
writeKept(GROUPING_KEY, storedGrouping, chosen);
},
}}
filter={{
filter: filter.filter,
onFilter: (chosen) => {
Expand Down
39 changes: 39 additions & 0 deletions packages/features/src/issue-list-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import {
clearedIssueFilter,
DEFAULT_ISSUE_FILTER,
filteredIssues,
ISSUE_GROUPING,
issueColumns,
issueFilterAnyPriority,
issueFilterChanged,
issueFilterCreatedBy,
issueFilterToggledPriority,
issueGroups,
issueIdentifier,
issueListGroups,
} from "./issue-list-model.js";
import { ISSUE_PRIORITY, ISSUE_STATUS, type Issue } from "./issue-wire.js";

Expand Down Expand Up @@ -219,3 +221,40 @@ test("choosing a creator or every priority changes that alone, the rest of the f
showCompleted: false,
});
});

test("grouped by priority, the list stands the most pressing first and an unweighed issue's last, leaves out a priority no issue holds, and lists the work under way first inside each", () => {
const groups = issueListGroups(
[
issue(1, { priority: ISSUE_PRIORITY.NONE }),
issue(2, { priority: ISSUE_PRIORITY.HIGH, status: ISSUE_STATUS.BACKLOG, updatedAt: 90 }),
issue(3, { priority: ISSUE_PRIORITY.HIGH, status: ISSUE_STATUS.IN_PROGRESS, updatedAt: 10 }),
issue(4, { priority: ISSUE_PRIORITY.URGENT, status: ISSUE_STATUS.DONE }),
issue(5, { priority: ISSUE_PRIORITY.HIGH, status: ISSUE_STATUS.BACKLOG, updatedAt: 95 }),
],
ISSUE_GROUPING.PRIORITY,
);

assert.deepEqual(
groups.map((group) => [group.words, numbers(group.issues)]),
[
["Urgent", [4]],
["High", [3, 5, 2]],
["No priority", [1]],
],
);
});

test("grouped by status, the list's headings are the status groups in their words", () => {
const groups = issueListGroups(
[issue(1, { status: ISSUE_STATUS.DONE }), issue(2, { status: ISSUE_STATUS.IN_PROGRESS })],
ISSUE_GROUPING.STATUS,
);

assert.deepEqual(
groups.map((group) => [group.words, numbers(group.issues)]),
[
["In progress", [2]],
["Done", [1]],
],
);
});
Loading
Loading