Доска: закрытые задачи и их связи приглушены (#57)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c5e33a9cc4
commit
80cff5bcfb
@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useClosedStatuses } from "../api/closed.ts";
|
||||
import { useSettings } from "../api/queries.ts";
|
||||
import { computeVisible, emptyReason } from "./analysis/visible.ts";
|
||||
import { BoardCanvas } from "./components/BoardCanvas.tsx";
|
||||
@ -17,6 +18,7 @@ import "./board.css";
|
||||
|
||||
export function BoardPage() {
|
||||
const settings = useSettings();
|
||||
const closedStatuses = useClosedStatuses();
|
||||
const d = useMemo(() => (settings.data ? dictsFromSettings(settings.data) : null), [settings.data]);
|
||||
const src = useBoardSource(d && !d.missing.length ? d.dicts : null);
|
||||
const prefs = useBoardPrefs(src.model);
|
||||
@ -76,7 +78,7 @@ export function BoardPage() {
|
||||
</div>
|
||||
{exportText !== null && <ExportBox text={exportText} onClose={() => setExportText(null)} />}
|
||||
<BoardCanvas vis={vis} byDate={prefs.filters.byDate} snap={prefs.snap} today={today} store={prefs.store}
|
||||
sprintSel={prefs.filters.sprints} emptyText={vis.nodes.length ? "" : emptyReason(src.model, vis, prefs.filters)}
|
||||
sprintSel={prefs.filters.sprints} emptyText={vis.nodes.length ? "" : emptyReason(src.model, vis, prefs.filters)} closedStatuses={closedStatuses}
|
||||
onToggleLane={prefs.toggleLane} onMoveLane={(name, to) => prefs.moveLane(vis.lanes.map((l) => l.name), name, to)}
|
||||
onColWidth={prefs.setColWidth} saveRef={saveRef}>
|
||||
{prefs.problems && <ProblemsDrawer problems={vis.problems} onClose={() => prefs.toggle("problems")} />}
|
||||
|
||||
@ -90,6 +90,9 @@ svg.guides line{stroke:var(--guide);stroke-width:1;stroke-dasharray:4 3}
|
||||
.card{position:absolute;width:240px;height:120px;box-sizing:border-box;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:12px 14px;display:flex;flex-direction:column;gap:4px;overflow:hidden;cursor:default;touch-action:none}
|
||||
.card:hover{border-color:var(--accent)}
|
||||
.card.out{border-style:dashed;opacity:.7}
|
||||
.card.closed{opacity:.4}
|
||||
.card.closed:hover,.card.closed:focus-within{opacity:1}
|
||||
.edges path.closed{opacity:.4}
|
||||
.card.crit{box-shadow:0 0 0 2px var(--guide)}
|
||||
.card.focus{box-shadow:0 0 0 2px var(--accent)}
|
||||
.card.drag{cursor:grabbing;border-color:var(--accent);box-shadow:0 6px 18px rgba(0,0,0,.18);z-index:2}
|
||||
|
||||
@ -32,6 +32,41 @@ function draw(over: Partial<Parameters<typeof BoardCanvas>[0]> = {}) {
|
||||
const board = () => document.querySelector(".board") as HTMLElement;
|
||||
|
||||
describe("BoardCanvas", () => {
|
||||
// закрытые (hide-closed): src первой видимой связи — «Готово», её dst — «Снято» (плюс закрытые из фикстуры)
|
||||
function closedVis() {
|
||||
const vis = baseProps().vis;
|
||||
const l = vis.links[0];
|
||||
const st: Record<string, string> = { [l.src]: "Готово", [l.dst]: "Снято" };
|
||||
return { vis: { ...vis, nodes: vis.nodes.map((n) => (st[n.key] ? { ...n, status: st[n.key] } : n)) }, l };
|
||||
}
|
||||
const CLOSED = new Set(["Готово", "Снято"]);
|
||||
|
||||
it("закрытые карточки и стрелки, у которых закрыт хоть один конец, — класс closed", () => {
|
||||
const { vis, l } = closedVis();
|
||||
draw({ vis, closedStatuses: CLOSED });
|
||||
const keys = vis.nodes.filter((n) => CLOSED.has(n.status)).map((n) => n.key); // в фикстуре ещё AURORA-9006 «Готово»
|
||||
expect(keys).toEqual(expect.arrayContaining([l.src, l.dst]));
|
||||
const closedCards = [...board().querySelectorAll(".card.closed[data-key]")].map((e) => e.getAttribute("data-key")).sort();
|
||||
expect(closedCards).toEqual([...keys].sort());
|
||||
const touching = vis.links.filter((x) => keys.includes(x.src) || keys.includes(x.dst)).length;
|
||||
expect(document.querySelectorAll("svg.edges > path.closed")).toHaveLength(touching);
|
||||
expect(document.querySelectorAll("svg.edges > path:not(.closed)").length).toBe(vis.links.length - touching);
|
||||
expect(document.querySelectorAll(".measure .card.closed")).toHaveLength(0); // слой измерения — без приглушения
|
||||
});
|
||||
|
||||
it("без closedStatuses — ничего не приглушено", () => {
|
||||
draw({ vis: closedVis().vis });
|
||||
expect(document.querySelectorAll(".card.closed, svg.edges > path.closed")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("закрытый сосед — и out, и closed", () => {
|
||||
const { vis, l } = closedVis();
|
||||
draw({ vis: { ...vis, nodes: vis.nodes.map((n) => (n.key === l.src ? { ...n, dim: true } : n)) }, closedStatuses: CLOSED });
|
||||
const card = board().querySelector(`.card[data-key="${l.src}"]`)!;
|
||||
expect(card).toHaveClass("out");
|
||||
expect(card).toHaveClass("closed");
|
||||
});
|
||||
|
||||
it("дорожки по эпикам и карточки задач с заглушкой", () => {
|
||||
draw();
|
||||
const lanes = [...board().querySelectorAll(".lane-name")].map((e) => e.textContent);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type ReactNode } from "react";
|
||||
import { isClosed } from "../../api/closed.ts";
|
||||
import { CW, GRID, MINH, MINW, TOP } from "../constants.ts";
|
||||
import { alignEdges } from "../layout/align.ts";
|
||||
import { clampToCol, computeLayout, measuredHeight, place, type Layout, type Placed, type RelBox } from "../layout/layout.ts";
|
||||
@ -15,6 +16,7 @@ export interface LayoutStore { laneOrder: string[]; folded: Set<string>; colWidt
|
||||
export interface BoardCanvasProps {
|
||||
vis: Visible; byDate: boolean; snap: boolean; today: string; store: LayoutStore; sprintSel: Set<string>;
|
||||
emptyText: string;
|
||||
closedStatuses?: Set<string>; // статусы Jira закрытых задач: их карточки и стрелки приглушены (hide-closed), на модель не влияют
|
||||
saveRef?: { current: (() => void) | null };
|
||||
onToggleLane(name: string): void;
|
||||
onMoveLane(name: string, to: number): void;
|
||||
@ -22,7 +24,9 @@ export interface BoardCanvasProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) {
|
||||
const NONE = new Set<string>();
|
||||
|
||||
export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyText, closedStatuses = NONE, onToggleLane, onMoveLane, onColWidth, saveRef, children }: BoardCanvasProps) {
|
||||
const boardRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
@ -33,6 +37,7 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
|
||||
const [guides, setGuides] = useState<{ x: number | null; y: number | null } | null>(null);
|
||||
const [scroll, setScroll] = useState({ left: 0, wide: false, wrapW: 0, scrollW: 0 });
|
||||
const [dragTarget, setDragTarget] = useState<{ from: number; to: number } | null>(null);
|
||||
const closedKeys = useMemo(() => new Set(vis.nodes.filter((n) => isClosed(n.status, closedStatuses)).map((n) => n.key)), [vis.nodes, closedStatuses]);
|
||||
|
||||
// раскладка как render() links.html: при новой выборке, режиме и ширинах колонок — заново, с сохранённой из хранилища
|
||||
useLayoutEffect(() => {
|
||||
@ -185,9 +190,9 @@ export function BoardCanvas({ vis, byDate, snap, today, store, sprintSel, emptyT
|
||||
))}
|
||||
{Object.entries(placed.pos).filter(([k]) => byKey[k]).map(([k, box]) => (
|
||||
<Card key={k} node={byKey[k]} box={box} rel={rel[k]} multiSprint={multiSprint} sprintSel={sprintSel}
|
||||
onPointerDown={onCardDown} onAutoSize={onAutoSize} />
|
||||
closed={closedKeys.has(k)} onPointerDown={onCardDown} onAutoSize={onAutoSize} />
|
||||
))}
|
||||
<Edges links={vis.links} placed={placed} />
|
||||
<Edges links={vis.links} placed={placed} closedKeys={closedKeys} />
|
||||
<Guides x={guides?.x ?? null} y={guides?.y ?? null} width={placed.width} height={placed.height} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -12,5 +12,6 @@ describe("cardPropsEqual (T11.1)", () => {
|
||||
expect(cardPropsEqual(base, { ...base, box: { x: 9, y: 2, w: 240, h: 120 } })).toBe(false);
|
||||
expect(cardPropsEqual(base, { ...base, node: { key: "A-2" } as CardProps["node"] })).toBe(false);
|
||||
expect(cardPropsEqual(base, { ...base, sprintSel: new Set(["S"]) })).toBe(false);
|
||||
expect(cardPropsEqual(base, { ...base, closed: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -12,6 +12,7 @@ export interface CardProps {
|
||||
rel?: RelBox;
|
||||
multiSprint: boolean;
|
||||
sprintSel: Set<string>;
|
||||
closed?: boolean; // задача закрыта (статус из closed_statuses) — карточка приглушена
|
||||
onPointerDown?: (ev: PointerEvent<HTMLDivElement>, key: string) => void;
|
||||
onAutoSize?: (key: string) => void;
|
||||
}
|
||||
@ -19,12 +20,12 @@ export interface CardProps {
|
||||
// T11.1: при перетаскивании одной карточки place() создаёт новые box у всех — сравниваем box по значению
|
||||
export function cardPropsEqual(a: CardProps, b: CardProps): boolean {
|
||||
const sameBox = a.box === b.box || (!!a.box && !!b.box && a.box.x === b.box.x && a.box.y === b.box.y && a.box.w === b.box.w && a.box.h === b.box.h);
|
||||
return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel &&
|
||||
return sameBox && a.node === b.node && a.rel === b.rel && a.multiSprint === b.multiSprint && a.sprintSel === b.sprintSel && !!a.closed === !!b.closed &&
|
||||
a.onPointerDown === b.onPointerDown && a.onAutoSize === b.onAutoSize;
|
||||
}
|
||||
|
||||
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, onPointerDown, onAutoSize }: CardProps) {
|
||||
const cls = "card" + (n.dim || n.stub ? " out" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
|
||||
export const Card = memo(function Card({ node: n, box, rel, multiSprint, sprintSel, closed, onPointerDown, onAutoSize }: CardProps) {
|
||||
const cls = "card" + (n.dim || n.stub ? " out" : "") + (closed ? " closed" : "") + (n.crit ? " crit" : "") + (n.focus ? " focus" : "");
|
||||
const style = box ? { left: box.x, top: box.y, width: box.w, height: box.h } : { width: CW };
|
||||
const showTag = !n.stub && (multiSprint || n.dim); // метка спринта — если спринтов на доске больше одного или это сосед
|
||||
return (
|
||||
|
||||
@ -3,7 +3,7 @@ import { edgePath } from "../layout/edges.ts";
|
||||
import { RELATED } from "../constants.ts";
|
||||
import type { VisLink } from "../types.ts";
|
||||
|
||||
export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) {
|
||||
export function Edges({ links, placed, closedKeys }: { links: VisLink[]; placed: Placed; closedKeys?: Set<string> }) {
|
||||
return (
|
||||
<svg className="edges">
|
||||
<defs>
|
||||
@ -18,7 +18,8 @@ export function Edges({ links, placed }: { links: VisLink[]; placed: Placed }) {
|
||||
const a = placed.pos[l.src], b = placed.pos[l.dst];
|
||||
if (!a || !b) return null;
|
||||
const related = l.type === RELATED;
|
||||
return <path key={`${l.src}>${l.dst}${l.type}`} d={edgePath(a, b)} fill="none"
|
||||
const closed = !!closedKeys && (closedKeys.has(l.src) || closedKeys.has(l.dst)); // закрыт хоть один конец — приглушена
|
||||
return <path key={`${l.src}>${l.dst}${l.type}`} d={edgePath(a, b)} fill="none" className={closed ? "closed" : undefined}
|
||||
stroke={related ? "var(--edge)" : l.hand ? "var(--guide)" : "var(--edge-strong)"}
|
||||
strokeWidth={l.crit ? "4" : related ? "1.4" : "1.8"}
|
||||
strokeDasharray={related ? "5 4" : undefined}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user