feat: update kanban view, dashboard view, add backlog status
This commit is contained in:
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { err } from "neverthrow";
|
||||
import { InMemoryFileSystem } from "../../tests/helpers/InMemoryFileSystem";
|
||||
import { appError, AppErrorVariant } from "@/error";
|
||||
import { TaskStatus } from "@/model/taskStatus";
|
||||
import { FsTaskRepository } from "@/storage/FsTaskRepository";
|
||||
import { createTask } from "./createTask";
|
||||
|
||||
@@ -58,4 +59,15 @@ describe("createTask", () => {
|
||||
});
|
||||
expect(result).toEqual(err(appError(AppErrorVariant.NO_FOLDER)));
|
||||
});
|
||||
|
||||
it("creates with explicit status", async () => {
|
||||
const result = await createTask(repo, {
|
||||
folderPath: folder,
|
||||
title: "In column",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
});
|
||||
expect(result.isOk()).toBe(true);
|
||||
if (result.isErr()) return;
|
||||
expect(result.value.status).toBe(TaskStatus.IN_PROGRESS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,14 @@ import { errAsync, ResultAsync } from "neverthrow";
|
||||
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||
import { Task } from "@/model/task";
|
||||
import { TaskPriority } from "@/model/taskPriority";
|
||||
import { TaskStatus } from "@/model/taskStatus";
|
||||
import { isTaskStatus, TaskStatus } from "@/model/taskStatus";
|
||||
import { ITaskRepository } from "@/ports";
|
||||
|
||||
export type CreateTaskInput = {
|
||||
folderPath?: string;
|
||||
title?: string;
|
||||
/** Defaults to TODO when omitted. */
|
||||
status?: TaskStatus;
|
||||
};
|
||||
|
||||
export function createTask(
|
||||
@@ -23,11 +25,18 @@ export function createTask(
|
||||
return errAsync(appError(AppErrorVariant.EMPTY_TITLE));
|
||||
}
|
||||
|
||||
const status =
|
||||
input.status === undefined
|
||||
? TaskStatus.TODO
|
||||
: isTaskStatus(input.status)
|
||||
? input.status
|
||||
: TaskStatus.TODO;
|
||||
|
||||
return ResultAsync.fromSafePromise(
|
||||
repo.create(input.folderPath, {
|
||||
title,
|
||||
description: "",
|
||||
status: TaskStatus.TODO,
|
||||
status,
|
||||
priority: TaskPriority.MEDIUM,
|
||||
tags: [],
|
||||
assignee: "",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TaskLocation } from "@/model/taskLocation";
|
||||
import { TaskLocation, TaskScope } from "@/model/taskLocation";
|
||||
import { IConfigProvider } from "@/ports";
|
||||
|
||||
/** Active task folders from config (project first, then global). */
|
||||
@@ -7,12 +7,12 @@ export function resolveTaskLocations(config: IConfigProvider): TaskLocation[] {
|
||||
|
||||
const projectPath = config.getProjectTaskPath();
|
||||
if (projectPath) {
|
||||
locations.push({ scope: "project", folderPath: projectPath });
|
||||
locations.push({ scope: TaskScope.PROJECT, folderPath: projectPath });
|
||||
}
|
||||
|
||||
const globalPath = config.getGlobalTaskPath();
|
||||
if (globalPath) {
|
||||
locations.push({ scope: "global", folderPath: globalPath });
|
||||
locations.push({ scope: TaskScope.GLOBAL, folderPath: globalPath });
|
||||
}
|
||||
|
||||
return locations;
|
||||
|
||||
@@ -9,25 +9,23 @@ import {
|
||||
|
||||
describe("parseDashboardInboundMessage", () => {
|
||||
it("parses ready", () => {
|
||||
expect(parseDashboardInboundMessage({ type: "ready" })).toEqual(
|
||||
ok({ type: "ready" } satisfies DashboardInboundMessage),
|
||||
expect(parseDashboardInboundMessage({ variant: "ready" })).toEqual(
|
||||
ok({ variant: "ready" } satisfies DashboardInboundMessage),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses selectTask", () => {
|
||||
expect(
|
||||
parseDashboardInboundMessage({ type: "selectTask", id: "abc" }),
|
||||
).toEqual(ok({ type: "selectTask", id: "abc" }));
|
||||
parseDashboardInboundMessage({ variant: "selectTask", id: "abc" }),
|
||||
).toEqual(ok({ variant: "selectTask", id: "abc" }));
|
||||
});
|
||||
|
||||
it("parses setFilter", () => {
|
||||
const raw = {
|
||||
type: "setFilter",
|
||||
const raw = { variant: "setFilter",
|
||||
filter: { statuses: ["todo"], query: "login" },
|
||||
};
|
||||
expect(parseDashboardInboundMessage(raw)).toEqual(
|
||||
ok({
|
||||
type: "setFilter",
|
||||
ok({ variant: "setFilter",
|
||||
filter: { statuses: ["todo"], query: "login" },
|
||||
}),
|
||||
);
|
||||
@@ -35,23 +33,20 @@ describe("parseDashboardInboundMessage", () => {
|
||||
|
||||
it("parses setGroupBy", () => {
|
||||
expect(
|
||||
parseDashboardInboundMessage({
|
||||
type: "setGroupBy",
|
||||
parseDashboardInboundMessage({ variant: "setGroupBy",
|
||||
groupBy: GroupBy.STATUS,
|
||||
}),
|
||||
).toEqual(ok({ type: "setGroupBy", groupBy: GroupBy.STATUS }));
|
||||
).toEqual(ok({ variant: "setGroupBy", groupBy: GroupBy.STATUS }));
|
||||
});
|
||||
|
||||
it("parses saveDescription", () => {
|
||||
expect(
|
||||
parseDashboardInboundMessage({
|
||||
type: "saveDescription",
|
||||
parseDashboardInboundMessage({ variant: "saveDescription",
|
||||
id: "abc",
|
||||
description: "updated body",
|
||||
}),
|
||||
).toEqual(
|
||||
ok({
|
||||
type: "saveDescription",
|
||||
ok({ variant: "saveDescription",
|
||||
id: "abc",
|
||||
description: "updated body",
|
||||
}),
|
||||
@@ -59,14 +54,14 @@ describe("parseDashboardInboundMessage", () => {
|
||||
});
|
||||
|
||||
it("parses refresh", () => {
|
||||
expect(parseDashboardInboundMessage({ type: "refresh" })).toEqual(
|
||||
ok({ type: "refresh" }),
|
||||
expect(parseDashboardInboundMessage({ variant: "refresh" })).toEqual(
|
||||
ok({ variant: "refresh" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses createTask", () => {
|
||||
expect(parseDashboardInboundMessage({ type: "createTask" })).toEqual(
|
||||
ok({ type: "createTask" } satisfies DashboardInboundMessage),
|
||||
expect(parseDashboardInboundMessage({ variant: "createTask" })).toEqual(
|
||||
ok({ variant: "createTask" } satisfies DashboardInboundMessage),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -78,22 +73,21 @@ describe("parseDashboardInboundMessage", () => {
|
||||
});
|
||||
|
||||
it("rejects unknown type", () => {
|
||||
const result = parseDashboardInboundMessage({ type: "explode" });
|
||||
const result = parseDashboardInboundMessage({ variant: "explode" });
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isOk()) return;
|
||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||
});
|
||||
|
||||
it("rejects selectTask without id", () => {
|
||||
const result = parseDashboardInboundMessage({ type: "selectTask" });
|
||||
const result = parseDashboardInboundMessage({ variant: "selectTask" });
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isOk()) return;
|
||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||
});
|
||||
|
||||
it("rejects setGroupBy with invalid groupBy", () => {
|
||||
const result = parseDashboardInboundMessage({
|
||||
type: "setGroupBy",
|
||||
const result = parseDashboardInboundMessage({ variant: "setGroupBy",
|
||||
groupBy: "__not_a_group_by__",
|
||||
});
|
||||
expect(result.isErr()).toBe(true);
|
||||
@@ -102,8 +96,7 @@ describe("parseDashboardInboundMessage", () => {
|
||||
});
|
||||
|
||||
it("rejects saveDescription with non-string description", () => {
|
||||
const result = parseDashboardInboundMessage({
|
||||
type: "saveDescription",
|
||||
const result = parseDashboardInboundMessage({ variant: "saveDescription",
|
||||
id: "abc",
|
||||
description: 42,
|
||||
});
|
||||
|
||||
+21
-23
@@ -4,13 +4,13 @@ import { TaskFilter } from "./filterTasks";
|
||||
import { GroupBy, isGroupBy } from "./groupBy";
|
||||
|
||||
export type DashboardInboundMessage =
|
||||
| { type: "ready" }
|
||||
| { type: "selectTask"; id: string }
|
||||
| { type: "setFilter"; filter: TaskFilter }
|
||||
| { type: "setGroupBy"; groupBy: GroupBy }
|
||||
| { type: "saveDescription"; id: string; description: string }
|
||||
| { type: "refresh" }
|
||||
| { type: "createTask" };
|
||||
| { variant: "ready" }
|
||||
| { variant: "selectTask"; id: string }
|
||||
| { variant: "setFilter"; filter: TaskFilter }
|
||||
| { variant: "setGroupBy"; groupBy: GroupBy }
|
||||
| { variant: "saveDescription"; id: string; description: string }
|
||||
| { variant: "refresh" }
|
||||
| { variant: "createTask" };
|
||||
|
||||
function invalid(detail?: string): Result<DashboardInboundMessage, AppError> {
|
||||
return err(
|
||||
@@ -31,56 +31,54 @@ export function parseDashboardInboundMessage(
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const type = raw.type;
|
||||
if (typeof type !== "string") {
|
||||
const variant = raw.variant;
|
||||
if (typeof variant !== "string") {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
switch (variant) {
|
||||
case "ready":
|
||||
case "refresh":
|
||||
case "createTask":
|
||||
return ok({ type });
|
||||
return ok({ variant });
|
||||
|
||||
case "selectTask": {
|
||||
if (typeof raw.id !== "string" || raw.id.length === 0) {
|
||||
return invalid(type);
|
||||
return invalid(variant);
|
||||
}
|
||||
return ok({ type: "selectTask", id: raw.id });
|
||||
return ok({ variant: "selectTask", id: raw.id });
|
||||
}
|
||||
|
||||
case "setFilter": {
|
||||
if (!isRecord(raw.filter)) {
|
||||
return invalid(type);
|
||||
return invalid(variant);
|
||||
}
|
||||
return ok({
|
||||
type: "setFilter",
|
||||
return ok({ variant: "setFilter",
|
||||
filter: raw.filter as TaskFilter,
|
||||
});
|
||||
}
|
||||
|
||||
case "setGroupBy": {
|
||||
if (!isGroupBy(raw.groupBy)) {
|
||||
return invalid(type);
|
||||
return invalid(variant);
|
||||
}
|
||||
return ok({ type: "setGroupBy", groupBy: raw.groupBy });
|
||||
return ok({ variant: "setGroupBy", groupBy: raw.groupBy });
|
||||
}
|
||||
|
||||
case "saveDescription": {
|
||||
if (typeof raw.id !== "string" || raw.id.length === 0) {
|
||||
return invalid(type);
|
||||
return invalid(variant);
|
||||
}
|
||||
if (typeof raw.description !== "string") {
|
||||
return invalid(type);
|
||||
return invalid(variant);
|
||||
}
|
||||
return ok({
|
||||
type: "saveDescription",
|
||||
return ok({ variant: "saveDescription",
|
||||
id: raw.id,
|
||||
description: raw.description,
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return invalid(type);
|
||||
return invalid(variant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,12 @@ import { GroupBy, TaskGroup } from "./groupTasks";
|
||||
|
||||
/** Host → webview messages (presentation payload only). */
|
||||
export type DashboardOutboundMessage =
|
||||
| {
|
||||
type: "state";
|
||||
| { variant: "state";
|
||||
filter: TaskFilter;
|
||||
groupBy: GroupBy;
|
||||
selectedId: string | undefined;
|
||||
groups: TaskGroup[];
|
||||
selected: Task | null;
|
||||
}
|
||||
| { type: "error"; error: AppError }
|
||||
| { type: "saved"; task: Task };
|
||||
| { variant: "error"; error: AppError }
|
||||
| { variant: "saved"; task: Task };
|
||||
|
||||
+51
-12
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ok } from "neverthrow";
|
||||
import { AppErrorVariant } from "@/error";
|
||||
import { TaskScope } from "@/model/taskLocation";
|
||||
import { TaskStatus } from "@/model/taskStatus";
|
||||
import {
|
||||
parseKanbanInboundMessage,
|
||||
type KanbanInboundMessage,
|
||||
@@ -8,33 +10,60 @@ import {
|
||||
|
||||
describe("parseKanbanInboundMessage", () => {
|
||||
it("parses ready", () => {
|
||||
expect(parseKanbanInboundMessage({ type: "ready" })).toEqual(
|
||||
ok({ type: "ready" } satisfies KanbanInboundMessage),
|
||||
expect(parseKanbanInboundMessage({ variant: "ready" })).toEqual(
|
||||
ok({ variant: "ready" } satisfies KanbanInboundMessage),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses refresh", () => {
|
||||
expect(parseKanbanInboundMessage({ type: "refresh" })).toEqual(
|
||||
ok({ type: "refresh" }),
|
||||
expect(parseKanbanInboundMessage({ variant: "refresh" })).toEqual(
|
||||
ok({ variant: "refresh" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses moveTask", () => {
|
||||
expect(
|
||||
parseKanbanInboundMessage({
|
||||
type: "moveTask",
|
||||
variant: "moveTask",
|
||||
id: "abc",
|
||||
status: "in-progress",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
}),
|
||||
).toEqual(
|
||||
ok({
|
||||
type: "moveTask",
|
||||
variant: "moveTask",
|
||||
id: "abc",
|
||||
status: "in-progress",
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
} satisfies KanbanInboundMessage),
|
||||
);
|
||||
});
|
||||
|
||||
it("parses setScopeFilter", () => {
|
||||
expect(
|
||||
parseKanbanInboundMessage({
|
||||
variant: "setScopeFilter",
|
||||
scope: TaskScope.GLOBAL,
|
||||
}),
|
||||
).toEqual(ok({ variant: "setScopeFilter", scope: TaskScope.GLOBAL }));
|
||||
expect(
|
||||
parseKanbanInboundMessage({ variant: "setScopeFilter", scope: "all" }),
|
||||
).toEqual(ok({ variant: "setScopeFilter", scope: "all" }));
|
||||
});
|
||||
|
||||
it("parses setShowHidden", () => {
|
||||
expect(
|
||||
parseKanbanInboundMessage({ variant: "setShowHidden", showHidden: true }),
|
||||
).toEqual(ok({ variant: "setShowHidden", showHidden: true }));
|
||||
});
|
||||
|
||||
it("parses createTask", () => {
|
||||
expect(
|
||||
parseKanbanInboundMessage({
|
||||
variant: "createTask",
|
||||
status: TaskStatus.BACKLOG,
|
||||
}),
|
||||
).toEqual(ok({ variant: "createTask", status: TaskStatus.BACKLOG }));
|
||||
});
|
||||
|
||||
it("rejects non-objects", () => {
|
||||
const result = parseKanbanInboundMessage(null);
|
||||
expect(result.isErr()).toBe(true);
|
||||
@@ -43,7 +72,7 @@ describe("parseKanbanInboundMessage", () => {
|
||||
});
|
||||
|
||||
it("rejects unknown type", () => {
|
||||
const result = parseKanbanInboundMessage({ type: "explode" });
|
||||
const result = parseKanbanInboundMessage({ variant: "explode" });
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isOk()) return;
|
||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||
@@ -51,8 +80,8 @@ describe("parseKanbanInboundMessage", () => {
|
||||
|
||||
it("rejects moveTask without id", () => {
|
||||
const result = parseKanbanInboundMessage({
|
||||
type: "moveTask",
|
||||
status: "done",
|
||||
variant: "moveTask",
|
||||
status: TaskStatus.DONE,
|
||||
});
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isOk()) return;
|
||||
@@ -61,7 +90,7 @@ describe("parseKanbanInboundMessage", () => {
|
||||
|
||||
it("rejects moveTask with invalid status", () => {
|
||||
const result = parseKanbanInboundMessage({
|
||||
type: "moveTask",
|
||||
variant: "moveTask",
|
||||
id: "abc",
|
||||
status: "__not_a_status__",
|
||||
});
|
||||
@@ -69,4 +98,14 @@ describe("parseKanbanInboundMessage", () => {
|
||||
if (result.isOk()) return;
|
||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||
});
|
||||
|
||||
it("rejects setScopeFilter with invalid scope", () => {
|
||||
const result = parseKanbanInboundMessage({
|
||||
variant: "setScopeFilter",
|
||||
scope: "__not_a_scope__",
|
||||
});
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isOk()) return;
|
||||
expect(result.error.variant).toBe(AppErrorVariant.INVALID_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
+45
-15
@@ -1,11 +1,24 @@
|
||||
import { err, ok, Result } from "neverthrow";
|
||||
import { appError, AppError, AppErrorVariant } from "@/error";
|
||||
import { isTaskScope, type TaskScope } from "@/model/taskLocation";
|
||||
import { isTaskStatus, type TaskStatus } from "@/model/taskStatus";
|
||||
|
||||
/** Filter which scope's tasks are shown on the board. */
|
||||
export type KanbanScopeFilter = "all" | TaskScope;
|
||||
|
||||
export type KanbanInboundMessage =
|
||||
| { type: "ready" }
|
||||
| { type: "refresh" }
|
||||
| { type: "moveTask"; id: string; status: TaskStatus };
|
||||
| { variant: "ready" }
|
||||
| { variant: "refresh" }
|
||||
| { variant: "moveTask"; id: string; status: TaskStatus }
|
||||
| { variant: "setScopeFilter"; scope: KanbanScopeFilter }
|
||||
| { variant: "setShowHidden"; showHidden: boolean }
|
||||
| { variant: "createTask"; status: TaskStatus };
|
||||
|
||||
export function isKanbanScopeFilter(
|
||||
value: unknown,
|
||||
): value is KanbanScopeFilter {
|
||||
return value === "all" || isTaskScope(value);
|
||||
}
|
||||
|
||||
function invalid(detail?: string): Result<KanbanInboundMessage, AppError> {
|
||||
return err(
|
||||
@@ -26,31 +39,48 @@ export function parseKanbanInboundMessage(
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const type = raw.type;
|
||||
if (typeof type !== "string") {
|
||||
const variant = raw.variant;
|
||||
if (typeof variant !== "string") {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
switch (variant) {
|
||||
case "ready":
|
||||
case "refresh":
|
||||
return ok({ type });
|
||||
return ok({ variant });
|
||||
|
||||
case "moveTask": {
|
||||
if (typeof raw.id !== "string" || raw.id.length === 0) {
|
||||
return invalid(type);
|
||||
return invalid(variant);
|
||||
}
|
||||
if (!isTaskStatus(raw.status)) {
|
||||
return invalid(type);
|
||||
return invalid(variant);
|
||||
}
|
||||
return ok({
|
||||
type: "moveTask",
|
||||
id: raw.id,
|
||||
status: raw.status,
|
||||
});
|
||||
return ok({ variant: "moveTask", id: raw.id, status: raw.status });
|
||||
}
|
||||
|
||||
case "setScopeFilter": {
|
||||
if (!isKanbanScopeFilter(raw.scope)) {
|
||||
return invalid(variant);
|
||||
}
|
||||
return ok({ variant: "setScopeFilter", scope: raw.scope });
|
||||
}
|
||||
|
||||
case "setShowHidden": {
|
||||
if (typeof raw.showHidden !== "boolean") {
|
||||
return invalid(variant);
|
||||
}
|
||||
return ok({ variant: "setShowHidden", showHidden: raw.showHidden });
|
||||
}
|
||||
|
||||
case "createTask": {
|
||||
if (!isTaskStatus(raw.status)) {
|
||||
return invalid(variant);
|
||||
}
|
||||
return ok({ variant: "createTask", status: raw.status });
|
||||
}
|
||||
|
||||
default:
|
||||
return invalid(type);
|
||||
return invalid(variant);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { AppError } from "@/error";
|
||||
import { TaskScope } from "@/model/taskLocation";
|
||||
import { KanbanBoard } from "./buildKanbanBoard";
|
||||
import { KanbanScopeFilter } from "./messages";
|
||||
|
||||
/** Host → webview. */
|
||||
export type KanbanOutboundMessage =
|
||||
{ type: "state"; board: KanbanBoard } | { type: "error"; error: AppError };
|
||||
| {
|
||||
variant: "state";
|
||||
board: KanbanBoard;
|
||||
scopeFilter: KanbanScopeFilter;
|
||||
showHidden: boolean;
|
||||
availableScopes: TaskScope[];
|
||||
}
|
||||
| { variant: "error"; error: AppError };
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { Task } from "./task";
|
||||
|
||||
export type TaskScope = "project" | "global";
|
||||
export const TaskScope = {
|
||||
PROJECT: "project",
|
||||
GLOBAL: "global",
|
||||
} as const;
|
||||
|
||||
export type TaskScope = (typeof TaskScope)[keyof typeof TaskScope];
|
||||
|
||||
export const TASK_SCOPE_ORDER: TaskScope[] = [
|
||||
TaskScope.PROJECT,
|
||||
TaskScope.GLOBAL,
|
||||
];
|
||||
|
||||
export function isTaskScope(value: unknown): value is TaskScope {
|
||||
return (
|
||||
typeof value === "string" && (TASK_SCOPE_ORDER as string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a task lives on disk (runtime; not stored in frontmatter). */
|
||||
export type TaskLocation = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TaskScope } from "@/model/taskLocation";
|
||||
import {
|
||||
TASK_PRIORITY_META,
|
||||
TASK_PRIORITY_ORDER,
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
TASK_STATUS_ORDER,
|
||||
type TaskStatus,
|
||||
} from "@/model/taskStatus";
|
||||
import { TaskScope } from "@/model/taskLocation";
|
||||
|
||||
export { TASK_STATUS_ORDER, TASK_STATUS_META } from "@/model/taskStatus";
|
||||
export { TASK_PRIORITY_ORDER, TASK_PRIORITY_META } from "@/model/taskPriority";
|
||||
@@ -27,6 +27,6 @@ export const TASK_PRIORITY_LABELS: Record<TaskPriority, string> =
|
||||
) as Record<TaskPriority, string>;
|
||||
|
||||
export const TASK_SCOPE_LABELS: Record<TaskScope, string> = {
|
||||
project: "Project",
|
||||
global: "Global",
|
||||
[TaskScope.PROJECT]: "Project",
|
||||
[TaskScope.GLOBAL]: "Global",
|
||||
};
|
||||
|
||||
@@ -300,7 +300,7 @@ export function getTaskDashboardHtml(
|
||||
}
|
||||
|
||||
function postFilter() {
|
||||
post({ type: "setFilter", filter: normalizeFilter(state.filter) });
|
||||
post({ variant: "setFilter", filter: normalizeFilter(state.filter) });
|
||||
}
|
||||
|
||||
function renderChipRow(container, options, selectedValues, key) {
|
||||
@@ -341,8 +341,7 @@ export function getTaskDashboardHtml(
|
||||
|
||||
function saveDescription() {
|
||||
if (!state.selected) return;
|
||||
post({
|
||||
type: "saveDescription",
|
||||
post({ variant: "saveDescription",
|
||||
id: state.selected.id,
|
||||
description: el.description.value,
|
||||
});
|
||||
@@ -354,8 +353,8 @@ export function getTaskDashboardHtml(
|
||||
el.query.value = "";
|
||||
el.groupBy.value = GROUP_BY_NONE;
|
||||
renderFilterChips();
|
||||
post({ type: "setFilter", filter: {} });
|
||||
post({ type: "setGroupBy", groupBy: GROUP_BY_NONE });
|
||||
post({ variant: "setFilter", filter: {} });
|
||||
post({ variant: "setGroupBy", groupBy: GROUP_BY_NONE });
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
@@ -387,7 +386,7 @@ export function getTaskDashboardHtml(
|
||||
if (!okLeave) return;
|
||||
state.dirty = false;
|
||||
}
|
||||
post({ type: "selectTask", id: task.id });
|
||||
post({ variant: "selectTask", id: task.id });
|
||||
});
|
||||
el.list.appendChild(btn);
|
||||
}
|
||||
@@ -447,16 +446,16 @@ export function getTaskDashboardHtml(
|
||||
});
|
||||
|
||||
el.groupBy.addEventListener("change", () => {
|
||||
post({ type: "setGroupBy", groupBy: el.groupBy.value });
|
||||
post({ variant: "setGroupBy", groupBy: el.groupBy.value });
|
||||
});
|
||||
|
||||
el.refresh.addEventListener("click", () => {
|
||||
state.dirty = false;
|
||||
post({ type: "refresh" });
|
||||
post({ variant: "refresh" });
|
||||
});
|
||||
|
||||
el.createTask.addEventListener("click", () => {
|
||||
post({ type: "createTask" });
|
||||
post({ variant: "createTask" });
|
||||
});
|
||||
|
||||
el.resetFilters.addEventListener("click", () => {
|
||||
@@ -482,11 +481,11 @@ export function getTaskDashboardHtml(
|
||||
window.addEventListener("message", (event) => {
|
||||
const message = event.data;
|
||||
if (!message || typeof message !== "object") return;
|
||||
if (message.type === "state") {
|
||||
if (message.variant === "state") {
|
||||
applyState(message);
|
||||
} else if (message.type === "error") {
|
||||
} else if (message.variant === "error") {
|
||||
setStatus((message.error && message.error.message) || "Error", true);
|
||||
} else if (message.type === "saved") {
|
||||
} else if (message.variant === "saved") {
|
||||
state.dirty = false;
|
||||
state.selected = message.task;
|
||||
setStatus("Saved");
|
||||
@@ -495,7 +494,7 @@ export function getTaskDashboardHtml(
|
||||
});
|
||||
|
||||
renderFilterChips();
|
||||
post({ type: "ready" });
|
||||
post({ variant: "ready" });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -110,7 +110,7 @@ export class TaskDashboardPanel {
|
||||
const result = await listLocatedTasks(this.#deps.repo, locations);
|
||||
|
||||
if (result.isErr()) {
|
||||
this.#post({ type: "error", error: result.error });
|
||||
this.#post({ variant: "error", error: result.error });
|
||||
if (result.error.variant === AppErrorVariant.NO_FOLDER) {
|
||||
presentError(result.error);
|
||||
}
|
||||
@@ -130,14 +130,14 @@ export class TaskDashboardPanel {
|
||||
async #onMessage(raw: unknown): Promise<void> {
|
||||
const parsed = parseDashboardInboundMessage(raw);
|
||||
if (parsed.isErr()) {
|
||||
this.#post({ type: "error", error: parsed.error });
|
||||
this.#post({ variant: "error", error: parsed.error });
|
||||
return;
|
||||
}
|
||||
await this.#handleInbound(parsed.value);
|
||||
}
|
||||
|
||||
async #handleInbound(message: DashboardInboundMessage): Promise<void> {
|
||||
switch (message.type) {
|
||||
switch (message.variant) {
|
||||
case "ready":
|
||||
case "refresh":
|
||||
await this.reloadTasks();
|
||||
@@ -172,8 +172,7 @@ 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({
|
||||
type: "error",
|
||||
this.#post({ variant: "error",
|
||||
error: appError(AppErrorVariant.NOT_FOUND, { id }),
|
||||
});
|
||||
return;
|
||||
@@ -186,7 +185,7 @@ export class TaskDashboardPanel {
|
||||
});
|
||||
|
||||
if (result.isErr()) {
|
||||
this.#post({ type: "error", error: result.error });
|
||||
this.#post({ variant: "error", error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -197,7 +196,7 @@ export class TaskDashboardPanel {
|
||||
: item,
|
||||
);
|
||||
this.#selectedId = saved.id;
|
||||
this.#post({ type: "saved", task: saved });
|
||||
this.#post({ variant: "saved", task: saved });
|
||||
this.#postState();
|
||||
this.#deps.onTasksMutated?.();
|
||||
}
|
||||
@@ -210,8 +209,7 @@ export class TaskDashboardPanel {
|
||||
selectedId: this.#selectedId,
|
||||
});
|
||||
|
||||
this.#post({
|
||||
type: "state",
|
||||
this.#post({ variant: "state",
|
||||
filter: this.#filter,
|
||||
groupBy: this.#groupBy,
|
||||
selectedId: this.#selectedId,
|
||||
|
||||
+109
-14
@@ -1,4 +1,6 @@
|
||||
import * as vscode from "vscode";
|
||||
import { TaskScope } from "@/model/taskLocation";
|
||||
import { TASK_SCOPE_LABELS } from "@/ui/taskLabels";
|
||||
|
||||
const NONCE_LENGTH = 32;
|
||||
|
||||
@@ -13,6 +15,11 @@ export function getTaskKanbanHtml(
|
||||
`script-src 'nonce-${nonce}'`,
|
||||
].join("; ");
|
||||
|
||||
const scopeProject = JSON.stringify(TaskScope.PROJECT);
|
||||
const scopeGlobal = JSON.stringify(TaskScope.GLOBAL);
|
||||
const labelProject = JSON.stringify(TASK_SCOPE_LABELS[TaskScope.PROJECT]);
|
||||
const labelGlobal = JSON.stringify(TASK_SCOPE_LABELS[TaskScope.GLOBAL]);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -33,6 +40,7 @@ export function getTaskKanbanHtml(
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
@@ -42,21 +50,32 @@ export function getTaskKanbanHtml(
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
button {
|
||||
header .spacer { flex: 1; min-width: 8px; }
|
||||
select, button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
color: var(--vscode-input-foreground);
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
button {
|
||||
color: var(--vscode-button-foreground);
|
||||
background: var(--vscode-button-background);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
border-color: transparent;
|
||||
}
|
||||
button.secondary {
|
||||
background: var(--vscode-button-secondaryBackground, var(--vscode-input-background));
|
||||
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
|
||||
}
|
||||
button.column-add {
|
||||
padding: 0 6px;
|
||||
font-size: 1em;
|
||||
line-height: 1.2;
|
||||
min-width: 24px;
|
||||
}
|
||||
#status-line {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.85;
|
||||
@@ -65,10 +84,10 @@ export function getTaskKanbanHtml(
|
||||
#status-line.error { color: var(--vscode-errorForeground); }
|
||||
#board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
height: calc(100% - 48px);
|
||||
height: calc(100% - 52px);
|
||||
overflow: auto;
|
||||
align-items: stretch;
|
||||
}
|
||||
@@ -89,9 +108,11 @@ export function getTaskKanbanHtml(
|
||||
font-size: 0.9em;
|
||||
border-bottom: 1px solid var(--vscode-panel-border, var(--vscode-widget-border));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.column-header .title { flex: 1; }
|
||||
.column-header .count {
|
||||
opacity: 0.7;
|
||||
font-weight: 400;
|
||||
@@ -124,17 +145,31 @@ export function getTaskKanbanHtml(
|
||||
<body>
|
||||
<header>
|
||||
<h1>Kanban</h1>
|
||||
<label for="scopeFilter">Scope</label>
|
||||
<select id="scopeFilter" title="Project / Global filter"></select>
|
||||
<button id="toggleHidden" type="button" class="secondary" title="Show or hide backlog and cancelled">Show hidden</button>
|
||||
<span class="spacer"></span>
|
||||
<span id="status-line"></span>
|
||||
<button id="refresh" type="button" class="secondary" title="Refresh">↻</button>
|
||||
</header>
|
||||
<div id="board"></div>
|
||||
<script nonce="${nonce}">
|
||||
const vscode = acquireVsCodeApi();
|
||||
const SCOPE_PROJECT = ${scopeProject};
|
||||
const SCOPE_GLOBAL = ${scopeGlobal};
|
||||
const LABEL_PROJECT = ${labelProject};
|
||||
const LABEL_GLOBAL = ${labelGlobal};
|
||||
|
||||
const boardEl = document.getElementById("board");
|
||||
const statusLine = document.getElementById("status-line");
|
||||
const refreshBtn = document.getElementById("refresh");
|
||||
const scopeFilterEl = document.getElementById("scopeFilter");
|
||||
const toggleHiddenBtn = document.getElementById("toggleHidden");
|
||||
|
||||
let board = { columns: [] };
|
||||
let scopeFilter = "all";
|
||||
let showHidden = false;
|
||||
let availableScopes = [];
|
||||
let dragTaskId = null;
|
||||
|
||||
function post(message) {
|
||||
@@ -154,7 +189,38 @@ export function getTaskKanbanHtml(
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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 availableScopes) {
|
||||
options.push({ value: scope, label: scopeLabel(scope) });
|
||||
}
|
||||
scopeFilterEl.innerHTML = options
|
||||
.map(
|
||||
(opt) =>
|
||||
'<option value="' +
|
||||
escapeHtml(opt.value) +
|
||||
'"' +
|
||||
(opt.value === scopeFilter ? " selected" : "") +
|
||||
">" +
|
||||
escapeHtml(opt.label) +
|
||||
"</option>",
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderToggleHidden() {
|
||||
toggleHiddenBtn.textContent = showHidden ? "Hide backlog/cancelled" : "Show hidden";
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderScopeFilter();
|
||||
renderToggleHidden();
|
||||
boardEl.innerHTML = "";
|
||||
for (const column of board.columns || []) {
|
||||
const col = document.createElement("section");
|
||||
@@ -163,9 +229,27 @@ export function getTaskKanbanHtml(
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "column-header";
|
||||
header.innerHTML =
|
||||
"<span>" + escapeHtml(column.label) + "</span>" +
|
||||
'<span class="count">' + column.tasks.length + "</span>";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "title";
|
||||
title.textContent = column.label;
|
||||
|
||||
const count = document.createElement("span");
|
||||
count.className = "count";
|
||||
count.textContent = String(column.tasks.length);
|
||||
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "column-add secondary";
|
||||
addBtn.title = "Create task in " + column.label;
|
||||
addBtn.textContent = "+";
|
||||
addBtn.addEventListener("click", () => {
|
||||
post({ variant: "createTask", status: column.status });
|
||||
});
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(count);
|
||||
header.appendChild(addBtn);
|
||||
col.appendChild(header);
|
||||
|
||||
const body = document.createElement("div");
|
||||
@@ -185,7 +269,7 @@ export function getTaskKanbanHtml(
|
||||
const id = event.dataTransfer.getData("text/task-id") || dragTaskId;
|
||||
const status = column.status;
|
||||
if (!id) return;
|
||||
post({ type: "moveTask", id: id, status: status });
|
||||
post({ variant: "moveTask", id: id, status: status });
|
||||
dragTaskId = null;
|
||||
});
|
||||
|
||||
@@ -217,22 +301,33 @@ export function getTaskKanbanHtml(
|
||||
}
|
||||
|
||||
refreshBtn.addEventListener("click", () => {
|
||||
post({ type: "refresh" });
|
||||
post({ variant: "refresh" });
|
||||
});
|
||||
|
||||
scopeFilterEl.addEventListener("change", () => {
|
||||
post({ variant: "setScopeFilter", scope: scopeFilterEl.value });
|
||||
});
|
||||
|
||||
toggleHiddenBtn.addEventListener("click", () => {
|
||||
post({ variant: "setShowHidden", showHidden: !showHidden });
|
||||
});
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
const message = event.data;
|
||||
if (!message || typeof message !== "object") return;
|
||||
if (message.type === "state") {
|
||||
if (message.variant === "state") {
|
||||
board = message.board || { columns: [] };
|
||||
scopeFilter = message.scopeFilter || "all";
|
||||
showHidden = Boolean(message.showHidden);
|
||||
availableScopes = message.availableScopes || [];
|
||||
setStatus("");
|
||||
render();
|
||||
} else if (message.type === "error") {
|
||||
} else if (message.variant === "error") {
|
||||
setStatus((message.error && message.error.message) || "Error", true);
|
||||
}
|
||||
});
|
||||
|
||||
post({ type: "ready" });
|
||||
post({ variant: "ready" });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import * as vscode from "vscode";
|
||||
import { changeStatus } from "@/commands/changeStatus";
|
||||
import { createTask } from "@/commands/createTask";
|
||||
import { listLocatedTasks } from "@/commands/listLocatedTasks";
|
||||
import { resolveTaskLocations } from "@/commands/resolveTaskLocations";
|
||||
import { buildKanbanBoard } from "@/kanban/buildKanbanBoard";
|
||||
import {
|
||||
parseKanbanInboundMessage,
|
||||
type KanbanInboundMessage,
|
||||
type KanbanScopeFilter,
|
||||
} from "@/kanban/messages";
|
||||
import { KanbanOutboundMessage } from "@/kanban/outboundMessages";
|
||||
import { appError, AppErrorVariant } from "@/error";
|
||||
import { LocatedTask } from "@/model/taskLocation";
|
||||
import { TaskStatus } from "@/model/task";
|
||||
import { LocatedTask, TaskLocation, TaskScope } from "@/model/taskLocation";
|
||||
import { TaskStatus } from "@/model/taskStatus";
|
||||
import { IConfigProvider, ITaskRepository } from "@/ports";
|
||||
import { presentError } from "@/ui/presentError";
|
||||
import { TASK_SCOPE_LABELS } from "@/ui/taskLabels";
|
||||
import { createKanbanNonce, getTaskKanbanHtml } from "./taskKanbanHtml";
|
||||
|
||||
export type TaskKanbanDeps = {
|
||||
@@ -56,6 +59,8 @@ export class TaskKanbanPanel {
|
||||
readonly #deps: TaskKanbanDeps;
|
||||
|
||||
#tasks: LocatedTask[] = [];
|
||||
#scopeFilter: KanbanScopeFilter = "all";
|
||||
#showHidden = false;
|
||||
#disposed = false;
|
||||
|
||||
constructor(panel: vscode.WebviewPanel, deps: TaskKanbanDeps) {
|
||||
@@ -84,7 +89,7 @@ export class TaskKanbanPanel {
|
||||
const result = await listLocatedTasks(this.#deps.repo, locations);
|
||||
|
||||
if (result.isErr()) {
|
||||
this.#post({ type: "error", error: result.error });
|
||||
this.#post({ variant: "error", error: result.error });
|
||||
if (result.error.variant === AppErrorVariant.NO_FOLDER) {
|
||||
presentError(result.error);
|
||||
}
|
||||
@@ -98,14 +103,14 @@ export class TaskKanbanPanel {
|
||||
async #onMessage(raw: unknown): Promise<void> {
|
||||
const parsed = parseKanbanInboundMessage(raw);
|
||||
if (parsed.isErr()) {
|
||||
this.#post({ type: "error", error: parsed.error });
|
||||
this.#post({ variant: "error", error: parsed.error });
|
||||
return;
|
||||
}
|
||||
await this.#handleInbound(parsed.value);
|
||||
}
|
||||
|
||||
async #handleInbound(message: KanbanInboundMessage): Promise<void> {
|
||||
switch (message.type) {
|
||||
switch (message.variant) {
|
||||
case "ready":
|
||||
case "refresh":
|
||||
await this.reloadTasks();
|
||||
@@ -114,6 +119,20 @@ export class TaskKanbanPanel {
|
||||
case "moveTask":
|
||||
await this.#moveTask(message.id, message.status);
|
||||
return;
|
||||
|
||||
case "setScopeFilter":
|
||||
this.#scopeFilter = message.scope;
|
||||
this.#postState();
|
||||
return;
|
||||
|
||||
case "setShowHidden":
|
||||
this.#showHidden = message.showHidden;
|
||||
this.#postState();
|
||||
return;
|
||||
|
||||
case "createTask":
|
||||
await this.#createInColumn(message.status);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +140,7 @@ export class TaskKanbanPanel {
|
||||
const located = this.#tasks.find((item) => item.task.id === id);
|
||||
if (!located) {
|
||||
this.#post({
|
||||
type: "error",
|
||||
variant: "error",
|
||||
error: appError(AppErrorVariant.NOT_FOUND, { id }),
|
||||
});
|
||||
return;
|
||||
@@ -138,7 +157,7 @@ export class TaskKanbanPanel {
|
||||
});
|
||||
|
||||
if (result.isErr()) {
|
||||
this.#post({ type: "error", error: result.error });
|
||||
this.#post({ variant: "error", error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -152,9 +171,95 @@ export class TaskKanbanPanel {
|
||||
this.#deps.onTasksMutated?.();
|
||||
}
|
||||
|
||||
async #createInColumn(status: TaskStatus): Promise<void> {
|
||||
const location = await this.#pickLocationForCreate();
|
||||
if (location === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = await vscode.window.showInputBox({
|
||||
prompt: "Task title",
|
||||
placeHolder: "Enter task title...",
|
||||
});
|
||||
if (title === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createTask(this.#deps.repo, {
|
||||
folderPath: location.folderPath,
|
||||
title,
|
||||
status,
|
||||
});
|
||||
|
||||
if (result.isErr()) {
|
||||
presentError(result.error);
|
||||
this.#post({ variant: "error", error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.reloadTasks();
|
||||
this.#deps.onTasksMutated?.();
|
||||
}
|
||||
|
||||
async #pickLocationForCreate(): Promise<TaskLocation | undefined> {
|
||||
const locations = resolveTaskLocations(this.#deps.config);
|
||||
if (locations.length === 0) {
|
||||
presentError(appError(AppErrorVariant.NO_FOLDER));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.#scopeFilter !== "all") {
|
||||
const match = locations.find((loc) => loc.scope === this.#scopeFilter);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
if (locations.length === 1) {
|
||||
return locations[0];
|
||||
}
|
||||
|
||||
const picked = await vscode.window.showQuickPick(
|
||||
locations.map((location) => ({
|
||||
label: TASK_SCOPE_LABELS[location.scope],
|
||||
description: location.folderPath,
|
||||
location,
|
||||
})),
|
||||
{ placeHolder: "Create task in…" },
|
||||
);
|
||||
return picked?.location;
|
||||
}
|
||||
|
||||
#filteredTasks(): LocatedTask[] {
|
||||
if (this.#scopeFilter === "all") {
|
||||
return this.#tasks;
|
||||
}
|
||||
return this.#tasks.filter(
|
||||
(item) => item.location.scope === this.#scopeFilter,
|
||||
);
|
||||
}
|
||||
|
||||
#availableScopes(): TaskScope[] {
|
||||
const scopes = new Set(this.#tasks.map((item) => item.location.scope));
|
||||
// Also expose configured locations even if empty (for filter + create).
|
||||
for (const loc of resolveTaskLocations(this.#deps.config)) {
|
||||
scopes.add(loc.scope);
|
||||
}
|
||||
return [...scopes];
|
||||
}
|
||||
|
||||
#postState(): void {
|
||||
const board = buildKanbanBoard(this.#tasks.map((item) => item.task));
|
||||
this.#post({ type: "state", board });
|
||||
const board = buildKanbanBoard(
|
||||
this.#filteredTasks().map((item) => item.task),
|
||||
{ showHiddenStatuses: this.#showHidden },
|
||||
);
|
||||
this.#post({
|
||||
variant: "state",
|
||||
board,
|
||||
scopeFilter: this.#scopeFilter,
|
||||
showHidden: this.#showHidden,
|
||||
availableScopes: this.#availableScopes(),
|
||||
});
|
||||
}
|
||||
|
||||
#post(message: KanbanOutboundMessage): void {
|
||||
|
||||
@@ -57,13 +57,21 @@ headless/тестов можно оставить; editor-save — optional path
|
||||
- `+ Task` в toolbar → inbound `createTask` → host
|
||||
`executeCommand("xboctFlow.createTask")` → reload list
|
||||
|
||||
[ ] **Kanban**
|
||||
[x] **Kanban**
|
||||
|
||||
- кнопка переключения global\project
|
||||
- кнопка создания задачи на каждой колонке
|
||||
- колонка cancelled - по умолчанию скрыта, добавить кнопку для её отображения
|
||||
- кнопка переключения global\project (scope filter: All / Project / Global)
|
||||
- кнопка создания задачи на каждой колонке (`createTask` + status колонки)
|
||||
- колонки с `defaultHidden` (backlog, cancelled) скрыты по умолчанию; кнопка
|
||||
Show hidden / Hide backlog/cancelled
|
||||
|
||||
[ ] **Cтатус backlog**
|
||||
[x] **Cтатус backlog**
|
||||
|
||||
- добавить в фильтр
|
||||
- добавить в канбан
|
||||
- в модели (`TaskStatus.BACKLOG`), ORDER, chips dashboard из ORDER
|
||||
- в kanban: колонка при Show hidden; defaultHidden в meta
|
||||
|
||||
[ ] **Dashboard**
|
||||
|
||||
- добавить фильтр по global\project
|
||||
- в списке задач выводить её scope
|
||||
- сортировка - по умолчанию старые задачи сверху, но можно выбрать обратную
|
||||
сортировку
|
||||
|
||||
Reference in New Issue
Block a user