@echothink-ui/task 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/components/BackendThinkingChain.d.ts +2 -0
- package/dist/components/BlockingReasonPanel.d.ts +2 -0
- package/dist/components/DAGEdge.d.ts +2 -0
- package/dist/components/DAGLegend.d.ts +2 -0
- package/dist/components/DAGNode.d.ts +4 -0
- package/dist/components/DecisionRequiredPanel.d.ts +2 -0
- package/dist/components/HumanInterventionPanel.d.ts +2 -0
- package/dist/components/MobileTaskShell.d.ts +12 -0
- package/dist/components/TaskApprovalPanel.d.ts +2 -0
- package/dist/components/TaskCard.d.ts +2 -0
- package/dist/components/TaskDependencyList.d.ts +2 -0
- package/dist/components/TaskDetailPanel.d.ts +2 -0
- package/dist/components/TaskHandoffPanel.d.ts +2 -0
- package/dist/components/TaskProgressIndicator.d.ts +2 -0
- package/dist/components/TaskRetryPanel.d.ts +2 -0
- package/dist/components/TaskRunLog.d.ts +2 -0
- package/dist/components/TaskStatusBadge.d.ts +2 -0
- package/dist/components/TaskTable.d.ts +5 -0
- package/dist/components/TaskTimeline.d.ts +2 -0
- package/dist/components/TaskWaveDAG.d.ts +2 -0
- package/dist/components/TaskWaveHeader.d.ts +2 -0
- package/dist/components/TaskWaveTable.d.ts +2 -0
- package/dist/components/utils.d.ts +13 -0
- package/dist/index.cjs +2434 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +2402 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +2388 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +249 -0
- package/package.json +45 -0
- package/src/components/BackendThinkingChain.tsx +129 -0
- package/src/components/BlockingReasonPanel.tsx +67 -0
- package/src/components/DAGEdge.tsx +97 -0
- package/src/components/DAGLegend.tsx +86 -0
- package/src/components/DAGNode.tsx +103 -0
- package/src/components/DecisionRequiredPanel.tsx +166 -0
- package/src/components/HumanInterventionPanel.tsx +82 -0
- package/src/components/MobileTaskShell.tsx +52 -0
- package/src/components/TaskApprovalPanel.tsx +159 -0
- package/src/components/TaskCard.tsx +71 -0
- package/src/components/TaskDependencyList.test.tsx +54 -0
- package/src/components/TaskDependencyList.tsx +105 -0
- package/src/components/TaskDetailPanel.test.tsx +49 -0
- package/src/components/TaskDetailPanel.tsx +139 -0
- package/src/components/TaskHandoffPanel.tsx +125 -0
- package/src/components/TaskProgressIndicator.tsx +70 -0
- package/src/components/TaskRetryPanel.test.tsx +29 -0
- package/src/components/TaskRetryPanel.tsx +103 -0
- package/src/components/TaskRunLog.tsx +156 -0
- package/src/components/TaskStatusBadge.tsx +29 -0
- package/src/components/TaskTable.tsx +294 -0
- package/src/components/TaskTimeline.tsx +98 -0
- package/src/components/TaskWaveDAG.tsx +202 -0
- package/src/components/TaskWaveHeader.tsx +82 -0
- package/src/components/TaskWaveTable.tsx +151 -0
- package/src/components/css.d.ts +1 -0
- package/src/components/utils.ts +116 -0
- package/src/index.test.tsx +316 -0
- package/src/index.tsx +90 -0
- package/src/styles.css +2889 -0
- package/src/types.ts +289 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { ActionGroup, Badge, InlineNotification, Surface } from "@echothink-ui/core";
|
|
4
|
+
import type { EthSeverity } from "@echothink-ui/core";
|
|
5
|
+
import type { HumanInterventionPanelProps } from "../types";
|
|
6
|
+
|
|
7
|
+
function urgencySeverity(urgency: HumanInterventionPanelProps["urgency"]): EthSeverity {
|
|
8
|
+
if (urgency === "high") return "danger";
|
|
9
|
+
if (urgency === "medium") return "warning";
|
|
10
|
+
return "info";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function urgencyLabel(urgency: HumanInterventionPanelProps["urgency"]) {
|
|
14
|
+
if (urgency === "high") return "High urgency";
|
|
15
|
+
if (urgency === "medium") return "Medium urgency";
|
|
16
|
+
return "Low urgency";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function HumanInterventionPanel({
|
|
20
|
+
reason,
|
|
21
|
+
affectedTask,
|
|
22
|
+
actions,
|
|
23
|
+
urgency = "medium",
|
|
24
|
+
className,
|
|
25
|
+
title = "Human intervention required",
|
|
26
|
+
status = "approval-required",
|
|
27
|
+
severity,
|
|
28
|
+
role = "region",
|
|
29
|
+
"aria-labelledby": ariaLabelledBy,
|
|
30
|
+
...props
|
|
31
|
+
}: HumanInterventionPanelProps) {
|
|
32
|
+
const titleId = React.useId();
|
|
33
|
+
const panelSeverity = severity ?? urgencySeverity(urgency);
|
|
34
|
+
const labelledBy = ariaLabelledBy ?? (title ? titleId : undefined);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Surface
|
|
38
|
+
{...props}
|
|
39
|
+
role={role}
|
|
40
|
+
aria-labelledby={labelledBy}
|
|
41
|
+
className={clsx("eth-task-human-intervention-panel", className)}
|
|
42
|
+
title={title ? <span id={titleId}>{title}</span> : undefined}
|
|
43
|
+
status={status}
|
|
44
|
+
severity={panelSeverity}
|
|
45
|
+
data-eth-component="HumanInterventionPanel"
|
|
46
|
+
>
|
|
47
|
+
<InlineNotification severity={panelSeverity} title="Intervention requested">
|
|
48
|
+
{reason ?? "Review this task and choose the next action before the agent can continue."}
|
|
49
|
+
</InlineNotification>
|
|
50
|
+
|
|
51
|
+
<dl className="eth-task-human-intervention-panel__details" aria-label="Intervention details">
|
|
52
|
+
{affectedTask ? (
|
|
53
|
+
<div>
|
|
54
|
+
<dt>Affected task</dt>
|
|
55
|
+
<dd>
|
|
56
|
+
<span className="eth-task-human-intervention-panel__task-title">
|
|
57
|
+
{affectedTask.title}
|
|
58
|
+
</span>
|
|
59
|
+
<span className="eth-task-human-intervention-panel__task-id">{affectedTask.id}</span>
|
|
60
|
+
</dd>
|
|
61
|
+
</div>
|
|
62
|
+
) : null}
|
|
63
|
+
<div>
|
|
64
|
+
<dt>Urgency</dt>
|
|
65
|
+
<dd>
|
|
66
|
+
<Badge severity={panelSeverity}>{urgencyLabel(urgency)}</Badge>
|
|
67
|
+
</dd>
|
|
68
|
+
</div>
|
|
69
|
+
</dl>
|
|
70
|
+
|
|
71
|
+
{actions?.length ? (
|
|
72
|
+
<div
|
|
73
|
+
className="eth-task-human-intervention-panel__actions"
|
|
74
|
+
role="group"
|
|
75
|
+
aria-label="Intervention actions"
|
|
76
|
+
>
|
|
77
|
+
<ActionGroup actions={actions} />
|
|
78
|
+
</div>
|
|
79
|
+
) : null}
|
|
80
|
+
</Surface>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
|
|
4
|
+
export interface MobileTaskShellProps {
|
|
5
|
+
title?: React.ReactNode;
|
|
6
|
+
subtitle?: React.ReactNode;
|
|
7
|
+
progress?: React.ReactNode;
|
|
8
|
+
main?: React.ReactNode;
|
|
9
|
+
footer?: React.ReactNode;
|
|
10
|
+
identity?: React.ReactNode;
|
|
11
|
+
children?: React.ReactNode;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function MobileTaskShell({
|
|
16
|
+
title,
|
|
17
|
+
subtitle,
|
|
18
|
+
progress,
|
|
19
|
+
main,
|
|
20
|
+
footer,
|
|
21
|
+
identity,
|
|
22
|
+
children,
|
|
23
|
+
className
|
|
24
|
+
}: MobileTaskShellProps) {
|
|
25
|
+
const content = main ?? children;
|
|
26
|
+
const hasHeader = title || subtitle || progress;
|
|
27
|
+
const hasFooter = footer || identity;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<section
|
|
31
|
+
className={clsx("eth-mobile-task-shell", className)}
|
|
32
|
+
data-eth-component="MobileTaskShell"
|
|
33
|
+
>
|
|
34
|
+
{hasHeader ? (
|
|
35
|
+
<header className="eth-mobile-task-shell__header">
|
|
36
|
+
<div className="eth-mobile-task-shell__title-block">
|
|
37
|
+
{title ? <h1 className="eth-mobile-task-shell__title">{title}</h1> : null}
|
|
38
|
+
{subtitle ? <p className="eth-mobile-task-shell__subtitle">{subtitle}</p> : null}
|
|
39
|
+
</div>
|
|
40
|
+
{progress ? <div className="eth-mobile-task-shell__progress">{progress}</div> : null}
|
|
41
|
+
</header>
|
|
42
|
+
) : null}
|
|
43
|
+
<main className="eth-mobile-task-shell__main">{content}</main>
|
|
44
|
+
{hasFooter ? (
|
|
45
|
+
<footer className="eth-mobile-task-shell__footer">
|
|
46
|
+
{footer ? <div className="eth-mobile-task-shell__footer-main">{footer}</div> : null}
|
|
47
|
+
{identity ? <div className="eth-mobile-task-shell__identity">{identity}</div> : null}
|
|
48
|
+
</footer>
|
|
49
|
+
) : null}
|
|
50
|
+
</section>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Button, FormField, InlineNotification, Textarea } from "@echothink-ui/core";
|
|
3
|
+
import type { DecisionEvidence, TaskApprovalPanelProps } from "../types";
|
|
4
|
+
import { DecisionRequiredPanel } from "./DecisionRequiredPanel";
|
|
5
|
+
|
|
6
|
+
function refEvidence(
|
|
7
|
+
taskRef: React.ReactNode,
|
|
8
|
+
diffRef: React.ReactNode,
|
|
9
|
+
policyRef: React.ReactNode,
|
|
10
|
+
evidence: DecisionEvidence[] = []
|
|
11
|
+
): DecisionEvidence[] {
|
|
12
|
+
const refs: DecisionEvidence[] = [];
|
|
13
|
+
if (taskRef) refs.push({ id: "task-ref", label: "Task reference", preview: taskRef });
|
|
14
|
+
if (diffRef) refs.push({ id: "diff-ref", label: "Diff reference", preview: diffRef });
|
|
15
|
+
if (policyRef) refs.push({ id: "policy-ref", label: "Policy reference", preview: policyRef });
|
|
16
|
+
return [...refs, ...evidence];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function stateNotification(state: NonNullable<TaskApprovalPanelProps["state"]>) {
|
|
20
|
+
if (state === "approved") {
|
|
21
|
+
return {
|
|
22
|
+
severity: "success" as const,
|
|
23
|
+
title: "Output approved",
|
|
24
|
+
description: "The approval has been recorded with the current audit metadata."
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (state === "rejected") {
|
|
29
|
+
return {
|
|
30
|
+
severity: "danger" as const,
|
|
31
|
+
title: "Output rejected",
|
|
32
|
+
description: "The rejection reason and evidence references are retained for review."
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (state === "pending") {
|
|
37
|
+
return {
|
|
38
|
+
severity: "info" as const,
|
|
39
|
+
title: "Decision pending",
|
|
40
|
+
description: "The approval decision is being prepared for audit capture."
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function TaskApprovalPanel({
|
|
48
|
+
taskRef,
|
|
49
|
+
diffRef,
|
|
50
|
+
policyRef,
|
|
51
|
+
summary,
|
|
52
|
+
evidence,
|
|
53
|
+
onApprove,
|
|
54
|
+
onReject,
|
|
55
|
+
onRequestChanges,
|
|
56
|
+
state = "idle",
|
|
57
|
+
title = "Task approval required",
|
|
58
|
+
description,
|
|
59
|
+
footer,
|
|
60
|
+
...props
|
|
61
|
+
}: TaskApprovalPanelProps) {
|
|
62
|
+
const [reason, setReason] = React.useState("");
|
|
63
|
+
const reasonId = React.useId();
|
|
64
|
+
const notification = stateNotification(state);
|
|
65
|
+
const policy = policyRef ?? "Default task policy";
|
|
66
|
+
const isPending = state === "pending";
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<section className="eth-task-approval-panel" data-eth-component="TaskApprovalPanel">
|
|
70
|
+
<DecisionRequiredPanel
|
|
71
|
+
{...props}
|
|
72
|
+
title={title}
|
|
73
|
+
summary={
|
|
74
|
+
<div className="eth-task-approval-panel__body">
|
|
75
|
+
{summary ? <div className="eth-task-approval-panel__summary">{summary}</div> : null}
|
|
76
|
+
{notification ? (
|
|
77
|
+
<InlineNotification
|
|
78
|
+
className="eth-task-approval-panel__state"
|
|
79
|
+
severity={notification.severity}
|
|
80
|
+
title={notification.title}
|
|
81
|
+
>
|
|
82
|
+
{notification.description}
|
|
83
|
+
</InlineNotification>
|
|
84
|
+
) : null}
|
|
85
|
+
<FormField
|
|
86
|
+
id={reasonId}
|
|
87
|
+
label="Decision reason"
|
|
88
|
+
helperText="Stored with approval audit metadata."
|
|
89
|
+
className="eth-task-approval-panel__reason"
|
|
90
|
+
>
|
|
91
|
+
<Textarea
|
|
92
|
+
value={reason}
|
|
93
|
+
rows={4}
|
|
94
|
+
placeholder="Summarize the approval rationale, rejection issue, or requested change."
|
|
95
|
+
onChange={(event) => setReason(event.currentTarget.value)}
|
|
96
|
+
/>
|
|
97
|
+
</FormField>
|
|
98
|
+
<dl className="eth-task-approval-panel__audit">
|
|
99
|
+
<div>
|
|
100
|
+
<dt>Task</dt>
|
|
101
|
+
<dd>{taskRef ?? "-"}</dd>
|
|
102
|
+
</div>
|
|
103
|
+
{diffRef ? (
|
|
104
|
+
<div>
|
|
105
|
+
<dt>Diff</dt>
|
|
106
|
+
<dd>{diffRef}</dd>
|
|
107
|
+
</div>
|
|
108
|
+
) : null}
|
|
109
|
+
<div>
|
|
110
|
+
<dt>Policy</dt>
|
|
111
|
+
<dd>{policy}</dd>
|
|
112
|
+
</div>
|
|
113
|
+
</dl>
|
|
114
|
+
</div>
|
|
115
|
+
}
|
|
116
|
+
description={description ?? "Review the task output and choose an approval outcome."}
|
|
117
|
+
riskLevel="high"
|
|
118
|
+
evidence={refEvidence(taskRef, diffRef, policyRef, evidence)}
|
|
119
|
+
options={[]}
|
|
120
|
+
footer={
|
|
121
|
+
<div className="eth-task-approval-panel__footer">
|
|
122
|
+
<div
|
|
123
|
+
className="eth-task-approval-panel__actions"
|
|
124
|
+
role="group"
|
|
125
|
+
aria-label="Approval actions"
|
|
126
|
+
>
|
|
127
|
+
<Button
|
|
128
|
+
intent="success"
|
|
129
|
+
disabled={isPending || !onApprove}
|
|
130
|
+
aria-pressed={state === "approved"}
|
|
131
|
+
onClick={() => onApprove?.(reason)}
|
|
132
|
+
>
|
|
133
|
+
Approve
|
|
134
|
+
</Button>
|
|
135
|
+
<Button
|
|
136
|
+
intent="danger"
|
|
137
|
+
disabled={isPending || !onReject}
|
|
138
|
+
aria-pressed={state === "rejected"}
|
|
139
|
+
onClick={() => onReject?.(reason)}
|
|
140
|
+
>
|
|
141
|
+
Reject
|
|
142
|
+
</Button>
|
|
143
|
+
<Button
|
|
144
|
+
intent="secondary"
|
|
145
|
+
disabled={isPending || !onRequestChanges}
|
|
146
|
+
onClick={() => onRequestChanges?.(reason)}
|
|
147
|
+
>
|
|
148
|
+
Request changes
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
{footer ? (
|
|
152
|
+
<div className="eth-task-approval-panel__custom-footer">{footer}</div>
|
|
153
|
+
) : null}
|
|
154
|
+
</div>
|
|
155
|
+
}
|
|
156
|
+
/>
|
|
157
|
+
</section>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { Badge, Surface, Tag } from "@echothink-ui/core";
|
|
4
|
+
import type { TaskCardProps } from "../types";
|
|
5
|
+
import { formatDateTime, severityForPriority } from "./utils";
|
|
6
|
+
|
|
7
|
+
export function TaskCard({ task, actions, onSelect, className, ...props }: TaskCardProps) {
|
|
8
|
+
const cardActions = actions?.map((action) => ({
|
|
9
|
+
...action,
|
|
10
|
+
intent: action.intent ?? "tertiary"
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const interactiveProps = onSelect
|
|
14
|
+
? {
|
|
15
|
+
role: "button",
|
|
16
|
+
tabIndex: 0,
|
|
17
|
+
onClick: () => onSelect(task),
|
|
18
|
+
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
|
|
19
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
onSelect(task);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
: {};
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<Surface
|
|
29
|
+
{...props}
|
|
30
|
+
{...interactiveProps}
|
|
31
|
+
className={clsx("eth-task-card", onSelect && "eth-task-card--selectable", className)}
|
|
32
|
+
density="compact"
|
|
33
|
+
title={task.title}
|
|
34
|
+
subtitle={task.id}
|
|
35
|
+
description={task.description}
|
|
36
|
+
status={task.status}
|
|
37
|
+
actions={cardActions}
|
|
38
|
+
metadata={[
|
|
39
|
+
{
|
|
40
|
+
label: "Assignee",
|
|
41
|
+
value: task.assignee ? (
|
|
42
|
+
<span className="eth-task-card__assignee">{task.assignee}</span>
|
|
43
|
+
) : (
|
|
44
|
+
"-"
|
|
45
|
+
)
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
label: "Due",
|
|
49
|
+
value: formatDateTime(task.dueAt)
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
label: "Priority",
|
|
53
|
+
value: task.priority ? (
|
|
54
|
+
<Badge severity={severityForPriority(task.priority)}>{task.priority}</Badge>
|
|
55
|
+
) : (
|
|
56
|
+
"-"
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
]}
|
|
60
|
+
data-eth-component="TaskCard"
|
|
61
|
+
>
|
|
62
|
+
{task.tags?.length ? (
|
|
63
|
+
<div className="eth-task-card__tags" aria-label="Task tags">
|
|
64
|
+
{task.tags.map((tag) => (
|
|
65
|
+
<Tag key={tag}>{tag}</Tag>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
) : null}
|
|
69
|
+
</Surface>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { TaskDependencyList } from "./TaskDependencyList";
|
|
4
|
+
|
|
5
|
+
describe("@echothink-ui/task TaskDependencyList", () => {
|
|
6
|
+
it("renders explicit prerequisites and dependents with counts and status labels", () => {
|
|
7
|
+
render(
|
|
8
|
+
<TaskDependencyList
|
|
9
|
+
blockedBy={[{ id: "task-118", title: "Legal approval", status: "approval-required" }]}
|
|
10
|
+
blocks={[{ id: "task-173", title: "Publish launch landing page", status: "queued" }]}
|
|
11
|
+
/>
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const blockedBy = screen.getByRole("region", { name: "Blocked by" });
|
|
15
|
+
const blocks = screen.getByRole("region", { name: "Blocks" });
|
|
16
|
+
|
|
17
|
+
expect(within(blockedBy).getByText("1 task")).toBeTruthy();
|
|
18
|
+
expect(within(blockedBy).getByText("Prerequisite")).toBeTruthy();
|
|
19
|
+
expect(within(blockedBy).getByText("Legal approval")).toBeTruthy();
|
|
20
|
+
expect(within(blockedBy).getByText("Approval Required")).toBeTruthy();
|
|
21
|
+
expect(within(blocks).getByText("1 task")).toBeTruthy();
|
|
22
|
+
expect(within(blocks).getByText("Dependent")).toBeTruthy();
|
|
23
|
+
expect(within(blocks).getByText("Publish launch landing page")).toBeTruthy();
|
|
24
|
+
expect(within(blocks).getByText("Queued")).toBeTruthy();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("derives dependency groups from relationship metadata", () => {
|
|
28
|
+
render(
|
|
29
|
+
<TaskDependencyList
|
|
30
|
+
dependencies={[
|
|
31
|
+
{
|
|
32
|
+
id: "task-201",
|
|
33
|
+
title: "Security sign-off",
|
|
34
|
+
status: "pending-approval",
|
|
35
|
+
relationship: "blocked-by"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "task-202",
|
|
39
|
+
title: "Notify launch team",
|
|
40
|
+
status: "not-started",
|
|
41
|
+
relationship: "blocks"
|
|
42
|
+
}
|
|
43
|
+
]}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
expect(
|
|
48
|
+
within(screen.getByRole("region", { name: "Blocked by" })).getByText("task-201")
|
|
49
|
+
).toBeTruthy();
|
|
50
|
+
expect(
|
|
51
|
+
within(screen.getByRole("region", { name: "Blocks" })).getByText("task-202")
|
|
52
|
+
).toBeTruthy();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { Badge, EmptyState } from "@echothink-ui/core";
|
|
4
|
+
import type { TaskDependency, TaskDependencyListProps } from "../types";
|
|
5
|
+
import { TaskStatusBadge } from "./TaskStatusBadge";
|
|
6
|
+
|
|
7
|
+
type DependencyRelationship = "blocks" | "blocked-by";
|
|
8
|
+
|
|
9
|
+
const groupDescriptions: Record<DependencyRelationship, string> = {
|
|
10
|
+
"blocked-by": "Prerequisite work that must clear before this task can move forward.",
|
|
11
|
+
blocks: "Downstream work waiting on this task."
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function taskCountLabel(count: number) {
|
|
15
|
+
return `${count} task${count === 1 ? "" : "s"}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function relationshipLabel(relationship: DependencyRelationship) {
|
|
19
|
+
return relationship === "blocks" ? "Dependent" : "Prerequisite";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function DependencyGroup({
|
|
23
|
+
title,
|
|
24
|
+
relationship,
|
|
25
|
+
dependencies
|
|
26
|
+
}: {
|
|
27
|
+
title: string;
|
|
28
|
+
relationship: DependencyRelationship;
|
|
29
|
+
dependencies: TaskDependency[];
|
|
30
|
+
}) {
|
|
31
|
+
const headingId = React.useId();
|
|
32
|
+
|
|
33
|
+
if (!dependencies.length) return null;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<section className="eth-task-dependencies__group" aria-labelledby={headingId}>
|
|
37
|
+
<div className="eth-task-dependencies__group-header">
|
|
38
|
+
<div className="eth-task-dependencies__group-heading">
|
|
39
|
+
<h4 id={headingId}>{title}</h4>
|
|
40
|
+
<p>{groupDescriptions[relationship]}</p>
|
|
41
|
+
</div>
|
|
42
|
+
<Badge>{taskCountLabel(dependencies.length)}</Badge>
|
|
43
|
+
</div>
|
|
44
|
+
<ul className="eth-task-dependencies__list">
|
|
45
|
+
{dependencies.map((dependency) => (
|
|
46
|
+
<li
|
|
47
|
+
key={dependency.id}
|
|
48
|
+
className={clsx(
|
|
49
|
+
"eth-task-dependencies__item",
|
|
50
|
+
`eth-task-dependencies__item--${dependency.status}`
|
|
51
|
+
)}
|
|
52
|
+
data-status={dependency.status}
|
|
53
|
+
>
|
|
54
|
+
<div className="eth-task-dependencies__item-main">
|
|
55
|
+
<span className="eth-task-dependencies__relationship">
|
|
56
|
+
{relationshipLabel(relationship)}
|
|
57
|
+
</span>
|
|
58
|
+
<span className="eth-task-dependencies__title">{dependency.title}</span>
|
|
59
|
+
<span className="eth-task-dependencies__id">{dependency.id}</span>
|
|
60
|
+
</div>
|
|
61
|
+
<TaskStatusBadge status={dependency.status} />
|
|
62
|
+
</li>
|
|
63
|
+
))}
|
|
64
|
+
</ul>
|
|
65
|
+
</section>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function TaskDependencyList({
|
|
70
|
+
dependencies = [],
|
|
71
|
+
blocks = [],
|
|
72
|
+
blockedBy = [],
|
|
73
|
+
emptyTitle = "No dependencies",
|
|
74
|
+
className,
|
|
75
|
+
...props
|
|
76
|
+
}: TaskDependencyListProps) {
|
|
77
|
+
const derivedBlocks = dependencies.filter((dependency) => dependency.relationship === "blocks");
|
|
78
|
+
const derivedBlockedBy = dependencies.filter(
|
|
79
|
+
(dependency) => dependency.relationship !== "blocks"
|
|
80
|
+
);
|
|
81
|
+
const blockList = blocks.length ? blocks : derivedBlocks;
|
|
82
|
+
const blockedByList = blockedBy.length ? blockedBy : derivedBlockedBy;
|
|
83
|
+
const hasDependencies = blockList.length || blockedByList.length;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div
|
|
87
|
+
{...props}
|
|
88
|
+
className={clsx("eth-task-dependencies", className)}
|
|
89
|
+
data-eth-component="TaskDependencyList"
|
|
90
|
+
>
|
|
91
|
+
{hasDependencies ? (
|
|
92
|
+
<>
|
|
93
|
+
<DependencyGroup
|
|
94
|
+
title="Blocked by"
|
|
95
|
+
relationship="blocked-by"
|
|
96
|
+
dependencies={blockedByList}
|
|
97
|
+
/>
|
|
98
|
+
<DependencyGroup title="Blocks" relationship="blocks" dependencies={blockList} />
|
|
99
|
+
</>
|
|
100
|
+
) : (
|
|
101
|
+
<EmptyState title={emptyTitle} />
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { TaskDetailPanel } from "./TaskDetailPanel";
|
|
4
|
+
|
|
5
|
+
const task = {
|
|
6
|
+
id: "t1",
|
|
7
|
+
title: "Create landing page hero",
|
|
8
|
+
assignee: "JD",
|
|
9
|
+
dueAt: "2026-05-30T13:00:00Z",
|
|
10
|
+
status: "in-progress" as const,
|
|
11
|
+
priority: "high" as const,
|
|
12
|
+
description: "Design hero with social-proof modules and animated stat strip."
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("TaskDetailPanel", () => {
|
|
16
|
+
it("expands populated dependencies and history without duplicating actions", () => {
|
|
17
|
+
render(
|
|
18
|
+
<TaskDetailPanel
|
|
19
|
+
task={task}
|
|
20
|
+
dependencies={[
|
|
21
|
+
{
|
|
22
|
+
id: "d1",
|
|
23
|
+
title: "Approve brief",
|
|
24
|
+
status: "approval-required",
|
|
25
|
+
relationship: "blocked-by"
|
|
26
|
+
}
|
|
27
|
+
]}
|
|
28
|
+
events={[
|
|
29
|
+
{
|
|
30
|
+
id: "h1",
|
|
31
|
+
timestamp: "2026-05-25T10:00:00Z",
|
|
32
|
+
actor: "JD",
|
|
33
|
+
kind: "status_change",
|
|
34
|
+
summary: "Moved to in progress"
|
|
35
|
+
}
|
|
36
|
+
]}
|
|
37
|
+
actions={[{ id: "complete", label: "Complete", intent: "primary" }]}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(screen.getByText("Dependencies").closest("details")?.hasAttribute("open")).toBe(
|
|
42
|
+
true
|
|
43
|
+
);
|
|
44
|
+
expect(screen.getByText("History").closest("details")?.hasAttribute("open")).toBe(
|
|
45
|
+
true
|
|
46
|
+
);
|
|
47
|
+
expect(screen.getAllByRole("button", { name: "Complete" })).toHaveLength(1);
|
|
48
|
+
});
|
|
49
|
+
});
|