feat: dashboard - add sorting and scope
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Task } from "@/model/task";
|
||||
import { LocatedTask, TaskScope } from "@/model/taskLocation";
|
||||
import { TaskStatus } from "@/model/taskStatus";
|
||||
import { filterLocatedByScope } from "./filterLocatedByScope";
|
||||
|
||||
function located(
|
||||
id: string,
|
||||
scope: TaskScope,
|
||||
folderPath: string,
|
||||
): LocatedTask {
|
||||
const task: Task = {
|
||||
id,
|
||||
title: id,
|
||||
description: "",
|
||||
status: TaskStatus.TODO,
|
||||
priority: "medium",
|
||||
tags: [],
|
||||
assignee: "",
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
updated: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
return { task, location: { scope, folderPath } };
|
||||
}
|
||||
|
||||
describe("filterLocatedByScope", () => {
|
||||
const items = [
|
||||
located("p1", TaskScope.PROJECT, "/p"),
|
||||
located("g1", TaskScope.GLOBAL, "/g"),
|
||||
located("p2", TaskScope.PROJECT, "/p"),
|
||||
];
|
||||
|
||||
it("returns all when scope is all", () => {
|
||||
expect(filterLocatedByScope(items, "all")).toEqual(items);
|
||||
});
|
||||
|
||||
it("keeps only project scope", () => {
|
||||
const result = filterLocatedByScope(items, TaskScope.PROJECT);
|
||||
expect(result.map((i) => i.task.id)).toEqual(["p1", "p2"]);
|
||||
});
|
||||
|
||||
it("keeps only global scope", () => {
|
||||
const result = filterLocatedByScope(items, TaskScope.GLOBAL);
|
||||
expect(result.map((i) => i.task.id)).toEqual(["g1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { LocatedTask, TaskScope } from "@/model/taskLocation";
|
||||
|
||||
/** Same filter shape as kanban: all locations or one scope. */
|
||||
export type DashboardScopeFilter = "all" | TaskScope;
|
||||
|
||||
export function filterLocatedByScope(
|
||||
items: LocatedTask[],
|
||||
scope: DashboardScopeFilter,
|
||||
): LocatedTask[] {
|
||||
if (scope === "all") {
|
||||
return items;
|
||||
}
|
||||
return items.filter((item) => item.location.scope === scope);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ok } from "neverthrow";
|
||||
import { AppErrorVariant } from "@/error";
|
||||
import { TaskScope } from "@/model/taskLocation";
|
||||
import { GroupBy } from "./groupBy";
|
||||
import {
|
||||
parseDashboardInboundMessage,
|
||||
type DashboardInboundMessage,
|
||||
} from "./messages";
|
||||
import { TaskSortDirection } from "./sortTasks";
|
||||
|
||||
describe("parseDashboardInboundMessage", () => {
|
||||
it("parses ready", () => {
|
||||
@@ -21,11 +23,13 @@ describe("parseDashboardInboundMessage", () => {
|
||||
});
|
||||
|
||||
it("parses setFilter", () => {
|
||||
const raw = { variant: "setFilter",
|
||||
const raw = {
|
||||
variant: "setFilter",
|
||||
filter: { statuses: ["todo"], query: "login" },
|
||||
};
|
||||
expect(parseDashboardInboundMessage(raw)).toEqual(
|
||||
ok({ variant: "setFilter",
|
||||
ok({
|
||||
variant: "setFilter",
|
||||
filter: { statuses: ["todo"], query: "login" },
|
||||
}),
|
||||
);
|
||||
@@ -33,7 +37,8 @@ describe("parseDashboardInboundMessage", () => {
|
||||
|
||||
it("parses setGroupBy", () => {
|
||||
expect(
|
||||
parseDashboardInboundMessage({ variant: "setGroupBy",
|
||||
parseDashboardInboundMessage({
|
||||
variant: "setGroupBy",
|
||||
groupBy: GroupBy.STATUS,
|
||||
}),
|
||||
).toEqual(ok({ variant: "setGroupBy", groupBy: GroupBy.STATUS }));
|
||||
@@ -41,12 +46,14 @@ describe("parseDashboardInboundMessage", () => {
|
||||
|
||||
it("parses saveDescription", () => {
|
||||
expect(
|
||||
parseDashboardInboundMessage({ variant: "saveDescription",
|
||||
parseDashboardInboundMessage({
|
||||
variant: "saveDescription",
|
||||
id: "abc",
|
||||
description: "updated body",
|
||||
}),
|
||||
).toEqual(
|
||||
ok({ variant: "saveDescription",
|
||||
ok({
|
||||
variant: "saveDescription",
|
||||
id: "abc",
|
||||
description: "updated body",
|
||||
}),
|
||||
@@ -65,6 +72,40 @@ describe("parseDashboardInboundMessage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("parses setScopeFilter", () => {
|
||||
expect(
|
||||
parseDashboardInboundMessage({
|
||||
variant: "setScopeFilter",
|
||||
scope: TaskScope.GLOBAL,
|
||||
}),
|
||||
).toEqual(
|
||||
ok({
|
||||
variant: "setScopeFilter",
|
||||
scope: TaskScope.GLOBAL,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
parseDashboardInboundMessage({
|
||||
variant: "setScopeFilter",
|
||||
scope: "all",
|
||||
}),
|
||||
).toEqual(ok({ variant: "setScopeFilter", scope: "all" }));
|
||||
});
|
||||
|
||||
it("parses setSortDirection", () => {
|
||||
expect(
|
||||
parseDashboardInboundMessage({
|
||||
variant: "setSortDirection",
|
||||
direction: TaskSortDirection.DESC,
|
||||
}),
|
||||
).toEqual(
|
||||
ok({
|
||||
variant: "setSortDirection",
|
||||
direction: TaskSortDirection.DESC,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-objects", () => {
|
||||
const result = parseDashboardInboundMessage(null);
|
||||
expect(result.isErr()).toBe(true);
|
||||
@@ -87,7 +128,8 @@ describe("parseDashboardInboundMessage", () => {
|
||||
});
|
||||
|
||||
it("rejects setGroupBy with invalid groupBy", () => {
|
||||
const result = parseDashboardInboundMessage({ variant: "setGroupBy",
|
||||
const result = parseDashboardInboundMessage({
|
||||
variant: "setGroupBy",
|
||||
groupBy: "__not_a_group_by__",
|
||||
});
|
||||
expect(result.isErr()).toBe(true);
|
||||
@@ -96,7 +138,8 @@ describe("parseDashboardInboundMessage", () => {
|
||||
});
|
||||
|
||||
it("rejects saveDescription with non-string description", () => {
|
||||
const result = parseDashboardInboundMessage({ variant: "saveDescription",
|
||||
const result = parseDashboardInboundMessage({
|
||||
variant: "saveDescription",
|
||||
id: "abc",
|
||||
description: 42,
|
||||
});
|
||||
@@ -104,4 +147,14 @@ describe("parseDashboardInboundMessage", () => {
|
||||
if (result.isOk()) return;
|
||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||
});
|
||||
|
||||
it("rejects setSortDirection with invalid direction", () => {
|
||||
const result = parseDashboardInboundMessage({
|
||||
variant: "setSortDirection",
|
||||
direction: "__not_a_direction__",
|
||||
});
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isOk()) return;
|
||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { err, ok, Result } from "neverthrow";
|
||||
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||
import { isTaskScope, type TaskScope } from "@/model/taskLocation";
|
||||
import { TaskFilter } from "./filterTasks";
|
||||
import { GroupBy, isGroupBy } from "./groupBy";
|
||||
import { isTaskSortDirection, type TaskSortDirection } from "./sortTasks";
|
||||
|
||||
export type DashboardScopeFilter = "all" | TaskScope;
|
||||
|
||||
export function isDashboardScopeFilter(
|
||||
value: unknown,
|
||||
): value is DashboardScopeFilter {
|
||||
return value === "all" || isTaskScope(value);
|
||||
}
|
||||
|
||||
export type DashboardInboundMessage =
|
||||
| { variant: "ready" }
|
||||
@@ -10,7 +20,9 @@ export type DashboardInboundMessage =
|
||||
| { variant: "setGroupBy"; groupBy: GroupBy }
|
||||
| { variant: "saveDescription"; id: string; description: string }
|
||||
| { variant: "refresh" }
|
||||
| { variant: "createTask" };
|
||||
| { variant: "createTask" }
|
||||
| { variant: "setScopeFilter"; scope: DashboardScopeFilter }
|
||||
| { variant: "setSortDirection"; direction: TaskSortDirection };
|
||||
|
||||
function invalid(detail?: string): Result<DashboardInboundMessage, AppError> {
|
||||
return err(
|
||||
@@ -53,7 +65,8 @@ export function parseDashboardInboundMessage(
|
||||
if (!isRecord(raw.filter)) {
|
||||
return invalid(variant);
|
||||
}
|
||||
return ok({ variant: "setFilter",
|
||||
return ok({
|
||||
variant: "setFilter",
|
||||
filter: raw.filter as TaskFilter,
|
||||
});
|
||||
}
|
||||
@@ -72,12 +85,30 @@ export function parseDashboardInboundMessage(
|
||||
if (typeof raw.description !== "string") {
|
||||
return invalid(variant);
|
||||
}
|
||||
return ok({ variant: "saveDescription",
|
||||
return ok({
|
||||
variant: "saveDescription",
|
||||
id: raw.id,
|
||||
description: raw.description,
|
||||
});
|
||||
}
|
||||
|
||||
case "setScopeFilter": {
|
||||
if (!isDashboardScopeFilter(raw.scope)) {
|
||||
return invalid(variant);
|
||||
}
|
||||
return ok({ variant: "setScopeFilter", scope: raw.scope });
|
||||
}
|
||||
|
||||
case "setSortDirection": {
|
||||
if (!isTaskSortDirection(raw.direction)) {
|
||||
return invalid(variant);
|
||||
}
|
||||
return ok({
|
||||
variant: "setSortDirection",
|
||||
direction: raw.direction,
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return invalid(variant);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
import { AppError } from "@/error";
|
||||
import { Task } from "@/model/task";
|
||||
import { TaskScope } from "@/model/taskLocation";
|
||||
import { TaskFilter } from "./filterTasks";
|
||||
import { GroupBy, TaskGroup } from "./groupTasks";
|
||||
import { GroupBy } from "./groupBy";
|
||||
import { DashboardScopeFilter } from "./messages";
|
||||
import { TaskSortDirection } from "./sortTasks";
|
||||
|
||||
/** Task row for the list (domain task + scope for display). */
|
||||
export type DashboardListTask = Task & {
|
||||
scope: TaskScope;
|
||||
};
|
||||
|
||||
export type DashboardListGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
tasks: DashboardListTask[];
|
||||
};
|
||||
|
||||
/** Host → webview messages (presentation payload only). */
|
||||
export type DashboardOutboundMessage =
|
||||
| { variant: "state";
|
||||
| {
|
||||
variant: "state";
|
||||
filter: TaskFilter;
|
||||
groupBy: GroupBy;
|
||||
selectedId: string | undefined;
|
||||
groups: TaskGroup[];
|
||||
selected: Task | null;
|
||||
groups: DashboardListGroup[];
|
||||
selected: DashboardListTask | null;
|
||||
scopeFilter: DashboardScopeFilter;
|
||||
sortDirection: TaskSortDirection;
|
||||
availableScopes: TaskScope[];
|
||||
}
|
||||
| { variant: "error"; error: AppError }
|
||||
| { variant: "saved"; task: Task };
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Task } from "@/model/task";
|
||||
import { TaskStatus } from "@/model/taskStatus";
|
||||
import { sortTasks, TaskSortDirection } from "./sortTasks";
|
||||
|
||||
function task(
|
||||
partial: Partial<Task> & Pick<Task, "id" | "title" | "created">,
|
||||
): Task {
|
||||
return {
|
||||
description: "",
|
||||
status: TaskStatus.TODO,
|
||||
priority: "medium",
|
||||
tags: [],
|
||||
assignee: "",
|
||||
updated: "2026-01-01T00:00:00.000Z",
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("sortTasks", () => {
|
||||
const tasks: Task[] = [
|
||||
task({
|
||||
id: "new",
|
||||
title: "New",
|
||||
created: "2026-01-03T00:00:00.000Z",
|
||||
}),
|
||||
task({
|
||||
id: "old",
|
||||
title: "Old",
|
||||
created: "2026-01-01T00:00:00.000Z",
|
||||
}),
|
||||
task({
|
||||
id: "mid",
|
||||
title: "Mid",
|
||||
created: "2026-01-02T00:00:00.000Z",
|
||||
}),
|
||||
];
|
||||
|
||||
it("defaults to oldest first (created asc)", () => {
|
||||
expect(sortTasks(tasks).map((t) => t.id)).toEqual(["old", "mid", "new"]);
|
||||
});
|
||||
|
||||
it("sorts oldest first with ASC", () => {
|
||||
expect(sortTasks(tasks, TaskSortDirection.ASC).map((t) => t.id)).toEqual([
|
||||
"old",
|
||||
"mid",
|
||||
"new",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts newest first with DESC", () => {
|
||||
expect(sortTasks(tasks, TaskSortDirection.DESC).map((t) => t.id)).toEqual([
|
||||
"new",
|
||||
"mid",
|
||||
"old",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const copy = [...tasks];
|
||||
sortTasks(tasks, TaskSortDirection.DESC);
|
||||
expect(tasks.map((t) => t.id)).toEqual(copy.map((t) => t.id));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Task } from "@/model/task";
|
||||
|
||||
export const TaskSortDirection = {
|
||||
/** Oldest first (by created). */
|
||||
ASC: "asc",
|
||||
/** Newest first (by created). */
|
||||
DESC: "desc",
|
||||
} as const;
|
||||
|
||||
export type TaskSortDirection =
|
||||
(typeof TaskSortDirection)[keyof typeof TaskSortDirection];
|
||||
|
||||
export const TASK_SORT_DIRECTION_ORDER: TaskSortDirection[] = [
|
||||
TaskSortDirection.ASC,
|
||||
TaskSortDirection.DESC,
|
||||
];
|
||||
|
||||
export function isTaskSortDirection(
|
||||
value: unknown,
|
||||
): value is TaskSortDirection {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(TASK_SORT_DIRECTION_ORDER as string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
const SORT_ASC_SIGN = 1;
|
||||
const SORT_DESC_SIGN = -1;
|
||||
|
||||
/** Sort by `created`. Default: oldest first. Does not mutate input. */
|
||||
export function sortTasks(
|
||||
tasks: Task[],
|
||||
direction: TaskSortDirection = TaskSortDirection.ASC,
|
||||
): Task[] {
|
||||
const sign =
|
||||
direction === TaskSortDirection.ASC ? SORT_ASC_SIGN : SORT_DESC_SIGN;
|
||||
return [...tasks].sort(
|
||||
(a, b) =>
|
||||
sign * (new Date(a.created).getTime() - new Date(b.created).getTime()),
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import * as vscode from "vscode";
|
||||
import { GROUP_BY_META, GROUP_BY_ORDER, GroupBy } from "@/dashboard/groupBy";
|
||||
import {
|
||||
TaskSortDirection,
|
||||
TASK_SORT_DIRECTION_ORDER,
|
||||
} from "@/dashboard/sortTasks";
|
||||
import { TaskScope } from "@/model/taskLocation";
|
||||
import { TASK_PRIORITY_META, TASK_PRIORITY_ORDER } from "@/model/taskPriority";
|
||||
import { TASK_STATUS_META, TASK_STATUS_ORDER } from "@/model/taskStatus";
|
||||
import { GROUP_BY_META, GROUP_BY_ORDER, GroupBy } from "@/dashboard/groupBy";
|
||||
import { TASK_SCOPE_LABELS } from "@/ui/taskLabels";
|
||||
|
||||
const NONCE_LENGTH = 32;
|
||||
const QUERY_DEBOUNCE_MS = 200;
|
||||
@@ -31,6 +37,16 @@ function groupByOptionsHtml(): string {
|
||||
).join("");
|
||||
}
|
||||
|
||||
function sortDirectionOptionsHtml(): string {
|
||||
const labels: Record<string, string> = {
|
||||
[TaskSortDirection.ASC]: "Oldest first",
|
||||
[TaskSortDirection.DESC]: "Newest first",
|
||||
};
|
||||
return TASK_SORT_DIRECTION_ORDER.map(
|
||||
(value) => `<option value="${value}">${labels[value] ?? value}</option>`,
|
||||
).join("");
|
||||
}
|
||||
|
||||
/** Minimal split-layout document for the Task Dashboard webview. */
|
||||
export function getTaskDashboardHtml(
|
||||
webview: vscode.Webview,
|
||||
@@ -220,6 +236,14 @@ export function getTaskDashboardHtml(
|
||||
<div class="row">
|
||||
<input id="query" type="search" placeholder="Search title or body…" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="scopeFilter">Scope</label>
|
||||
<select id="scopeFilter" title="Project / Global"></select>
|
||||
<label for="sortDirection">Sort</label>
|
||||
<select id="sortDirection" title="Sort by created">
|
||||
${sortDirectionOptionsHtml()}
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="groupBy">Group</label>
|
||||
<select id="groupBy">
|
||||
@@ -253,6 +277,11 @@ export function getTaskDashboardHtml(
|
||||
const STATUSES = ${statusOptionsJson()};
|
||||
const PRIORITIES = ${priorityOptionsJson()};
|
||||
const GROUP_BY_NONE = ${JSON.stringify(GroupBy.NONE)};
|
||||
const SORT_ASC = ${JSON.stringify(TaskSortDirection.ASC)};
|
||||
const SCOPE_PROJECT = ${JSON.stringify(TaskScope.PROJECT)};
|
||||
const SCOPE_GLOBAL = ${JSON.stringify(TaskScope.GLOBAL)};
|
||||
const LABEL_PROJECT = ${JSON.stringify(TASK_SCOPE_LABELS[TaskScope.PROJECT])};
|
||||
const LABEL_GLOBAL = ${JSON.stringify(TASK_SCOPE_LABELS[TaskScope.GLOBAL])};
|
||||
|
||||
const state = {
|
||||
filter: {},
|
||||
@@ -261,12 +290,17 @@ export function getTaskDashboardHtml(
|
||||
groups: [],
|
||||
selected: null,
|
||||
dirty: false,
|
||||
scopeFilter: "all",
|
||||
sortDirection: SORT_ASC,
|
||||
availableScopes: [],
|
||||
};
|
||||
|
||||
const el = {
|
||||
list: document.getElementById("list"),
|
||||
query: document.getElementById("query"),
|
||||
groupBy: document.getElementById("groupBy"),
|
||||
scopeFilter: document.getElementById("scopeFilter"),
|
||||
sortDirection: document.getElementById("sortDirection"),
|
||||
refresh: document.getElementById("refresh"),
|
||||
createTask: document.getElementById("createTask"),
|
||||
resetFilters: document.getElementById("resetFilters"),
|
||||
@@ -281,6 +315,32 @@ export function getTaskDashboardHtml(
|
||||
statusLine: document.getElementById("status-line"),
|
||||
};
|
||||
|
||||
function scopeLabel(scope) {
|
||||
if (scope === SCOPE_PROJECT) return LABEL_PROJECT;
|
||||
if (scope === SCOPE_GLOBAL) return LABEL_GLOBAL;
|
||||
return scope || "";
|
||||
}
|
||||
|
||||
function renderScopeFilter() {
|
||||
const options = [{ value: "all", label: "All" }];
|
||||
for (const scope of state.availableScopes || []) {
|
||||
options.push({ value: scope, label: scopeLabel(scope) });
|
||||
}
|
||||
el.scopeFilter.innerHTML = options
|
||||
.map(function (opt) {
|
||||
return (
|
||||
'<option value="' +
|
||||
escapeHtml(opt.value) +
|
||||
'"' +
|
||||
(opt.value === state.scopeFilter ? " selected" : "") +
|
||||
">" +
|
||||
escapeHtml(opt.label) +
|
||||
"</option>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function post(message) {
|
||||
vscode.postMessage(message);
|
||||
}
|
||||
@@ -379,7 +439,13 @@ export function getTaskDashboardHtml(
|
||||
btn.className = "task" + (task.id === state.selectedId ? " selected" : "");
|
||||
btn.innerHTML =
|
||||
"<div>" + escapeHtml(task.title) + "</div>" +
|
||||
'<div class="meta">' + escapeHtml(task.status) + " · " + escapeHtml(task.priority) + "</div>";
|
||||
'<div class="meta">' +
|
||||
escapeHtml(scopeLabel(task.scope)) +
|
||||
" · " +
|
||||
escapeHtml(task.status) +
|
||||
" · " +
|
||||
escapeHtml(task.priority) +
|
||||
"</div>";
|
||||
btn.addEventListener("click", () => {
|
||||
if (state.dirty) {
|
||||
const okLeave = window.confirm("Discard unsaved description changes?");
|
||||
@@ -404,7 +470,11 @@ export function getTaskDashboardHtml(
|
||||
el.detail.classList.add("visible");
|
||||
el.title.textContent = task.title;
|
||||
el.meta.textContent =
|
||||
task.status + " · " + task.priority +
|
||||
scopeLabel(task.scope) +
|
||||
" · " +
|
||||
task.status +
|
||||
" · " +
|
||||
task.priority +
|
||||
(task.tags && task.tags.length ? " · " + task.tags.join(", ") : "") +
|
||||
(task.assignee ? " · @" + task.assignee : "");
|
||||
if (!state.dirty) {
|
||||
@@ -426,9 +496,14 @@ export function getTaskDashboardHtml(
|
||||
state.selectedId = message.selectedId;
|
||||
state.groups = message.groups || [];
|
||||
state.selected = message.selected;
|
||||
state.scopeFilter = message.scopeFilter || "all";
|
||||
state.sortDirection = message.sortDirection || SORT_ASC;
|
||||
state.availableScopes = message.availableScopes || [];
|
||||
state.dirty = false;
|
||||
el.query.value = state.filter.query || "";
|
||||
el.groupBy.value = state.groupBy;
|
||||
el.sortDirection.value = state.sortDirection;
|
||||
renderScopeFilter();
|
||||
renderFilterChips();
|
||||
renderList();
|
||||
renderDetail();
|
||||
@@ -449,6 +524,14 @@ export function getTaskDashboardHtml(
|
||||
post({ variant: "setGroupBy", groupBy: el.groupBy.value });
|
||||
});
|
||||
|
||||
el.scopeFilter.addEventListener("change", () => {
|
||||
post({ variant: "setScopeFilter", scope: el.scopeFilter.value });
|
||||
});
|
||||
|
||||
el.sortDirection.addEventListener("change", () => {
|
||||
post({ variant: "setSortDirection", direction: el.sortDirection.value });
|
||||
});
|
||||
|
||||
el.refresh.addEventListener("click", () => {
|
||||
state.dirty = false;
|
||||
post({ variant: "refresh" });
|
||||
@@ -487,7 +570,11 @@ export function getTaskDashboardHtml(
|
||||
setStatus((message.error && message.error.message) || "Error", true);
|
||||
} else if (message.variant === "saved") {
|
||||
state.dirty = false;
|
||||
state.selected = message.task;
|
||||
if (state.selected && state.selected.id === message.task.id) {
|
||||
state.selected = Object.assign({}, message.task, {
|
||||
scope: state.selected.scope,
|
||||
});
|
||||
}
|
||||
setStatus("Saved");
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
@@ -3,15 +3,21 @@ import { listLocatedTasks } from "@/commands/listLocatedTasks";
|
||||
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
||||
import { saveTaskDescription } from "@/commands/saveTaskDescription";
|
||||
import { buildDashboardView } from "@/dashboard/buildDashboardView";
|
||||
import { filterLocatedByScope } from "@/dashboard/filterLocatedByScope";
|
||||
import { TaskFilter } from "@/dashboard/filterTasks";
|
||||
import { GroupBy } from "@/dashboard/groupBy";
|
||||
import {
|
||||
parseDashboardInboundMessage,
|
||||
type DashboardInboundMessage,
|
||||
type DashboardScopeFilter,
|
||||
} from "@/dashboard/messages";
|
||||
import { DashboardOutboundMessage } from "@/dashboard/outboundMessages";
|
||||
import {
|
||||
DashboardListTask,
|
||||
DashboardOutboundMessage,
|
||||
} from "@/dashboard/outboundMessages";
|
||||
import { sortTasks, TaskSortDirection } from "@/dashboard/sortTasks";
|
||||
import { appError, AppErrorVariant } from "@/error";
|
||||
import { LocatedTask } from "@/model/taskLocation";
|
||||
import { LocatedTask, TaskScope } from "@/model/taskLocation";
|
||||
import { IConfigProvider, ITaskRepository } from "@/ports";
|
||||
import { presentError } from "@/ui/presentError";
|
||||
import { createNonce, getTaskDashboardHtml } from "./taskDashboardHtml";
|
||||
@@ -76,6 +82,8 @@ export class TaskDashboardPanel {
|
||||
#tasks: LocatedTask[] = [];
|
||||
#filter: TaskFilter = {};
|
||||
#groupBy: GroupBy = GroupBy.NONE;
|
||||
#scopeFilter: DashboardScopeFilter = "all";
|
||||
#sortDirection: TaskSortDirection = TaskSortDirection.ASC;
|
||||
#selectedId: string | undefined;
|
||||
#disposed = false;
|
||||
|
||||
@@ -158,6 +166,16 @@ export class TaskDashboardPanel {
|
||||
this.#postState();
|
||||
return;
|
||||
|
||||
case "setScopeFilter":
|
||||
this.#scopeFilter = message.scope;
|
||||
this.#postState();
|
||||
return;
|
||||
|
||||
case "setSortDirection":
|
||||
this.#sortDirection = message.direction;
|
||||
this.#postState();
|
||||
return;
|
||||
|
||||
case "saveDescription":
|
||||
await this.#saveDescription(message.id, message.description);
|
||||
return;
|
||||
@@ -172,7 +190,8 @@ export class TaskDashboardPanel {
|
||||
async #saveDescription(id: string, description: string): Promise<void> {
|
||||
const located = this.#tasks.find((item) => item.task.id === id);
|
||||
if (!located) {
|
||||
this.#post({ variant: "error",
|
||||
this.#post({
|
||||
variant: "error",
|
||||
error: appError(AppErrorVariant.NOT_FOUND, { id }),
|
||||
});
|
||||
return;
|
||||
@@ -201,20 +220,61 @@ export class TaskDashboardPanel {
|
||||
this.#deps.onTasksMutated?.();
|
||||
}
|
||||
|
||||
#scopeById(): Map<string, TaskScope> {
|
||||
return new Map(
|
||||
this.#tasks.map((item) => [item.task.id, item.location.scope]),
|
||||
);
|
||||
}
|
||||
|
||||
#withScope(task: import("@/model/task").Task): DashboardListTask | null {
|
||||
const scope = this.#scopeById().get(task.id);
|
||||
if (scope === undefined) {
|
||||
return null;
|
||||
}
|
||||
return { ...task, scope };
|
||||
}
|
||||
|
||||
#availableScopes(): TaskScope[] {
|
||||
const scopes = new Set(this.#tasks.map((item) => item.location.scope));
|
||||
for (const loc of resolveTaskLocations(this.#deps.config)) {
|
||||
scopes.add(loc.scope);
|
||||
}
|
||||
return [...scopes];
|
||||
}
|
||||
|
||||
#postState(): void {
|
||||
const plainTasks = this.#tasks.map((item) => item.task);
|
||||
const view = buildDashboardView(plainTasks, {
|
||||
const scoped = filterLocatedByScope(this.#tasks, this.#scopeFilter);
|
||||
const plainSorted = sortTasks(
|
||||
scoped.map((item) => item.task),
|
||||
this.#sortDirection,
|
||||
);
|
||||
const view = buildDashboardView(plainSorted, {
|
||||
filter: this.#filter,
|
||||
groupBy: this.#groupBy,
|
||||
selectedId: this.#selectedId,
|
||||
});
|
||||
|
||||
this.#post({ variant: "state",
|
||||
const groups = view.groups.map((group) => ({
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
tasks: group.tasks
|
||||
.map((task) => this.#withScope(task))
|
||||
.filter((task): task is DashboardListTask => task !== null),
|
||||
}));
|
||||
|
||||
const selected =
|
||||
view.selected !== undefined ? this.#withScope(view.selected) : null;
|
||||
|
||||
this.#post({
|
||||
variant: "state",
|
||||
filter: this.#filter,
|
||||
groupBy: this.#groupBy,
|
||||
selectedId: this.#selectedId,
|
||||
groups: view.groups,
|
||||
selected: view.selected ?? null,
|
||||
groups,
|
||||
selected,
|
||||
scopeFilter: this.#scopeFilter,
|
||||
sortDirection: this.#sortDirection,
|
||||
availableScopes: this.#availableScopes(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -69,9 +69,9 @@ headless/тестов можно оставить; editor-save — optional path
|
||||
- в модели (`TaskStatus.BACKLOG`), ORDER, chips dashboard из ORDER
|
||||
- в kanban: колонка при Show hidden; defaultHidden в meta
|
||||
|
||||
[ ] **Dashboard**
|
||||
[x] **Dashboard**
|
||||
|
||||
- добавить фильтр по global\project
|
||||
- в списке задач выводить её scope
|
||||
- сортировка - по умолчанию старые задачи сверху, но можно выбрать обратную
|
||||
сортировку
|
||||
- фильтр scope: All / Project / Global
|
||||
- в списке и detail — scope (Project / Global)
|
||||
- сортировка по **created**: по умолчанию старые сверху (ASC), toggle Newest
|
||||
first
|
||||
|
||||
Reference in New Issue
Block a user