@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,294 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import {
|
|
4
|
+
ActionGroup,
|
|
5
|
+
Badge,
|
|
6
|
+
EmptyState,
|
|
7
|
+
Select,
|
|
8
|
+
StatusDot,
|
|
9
|
+
type EthOperationalStatus,
|
|
10
|
+
type SelectOption
|
|
11
|
+
} from "@echothink-ui/core";
|
|
12
|
+
import { BulkActionTable, DataTable, type DataColumn } from "@echothink-ui/data";
|
|
13
|
+
import type { Task, TaskPriority, TaskTableProps } from "../types";
|
|
14
|
+
import { TaskStatusBadge } from "./TaskStatusBadge";
|
|
15
|
+
import { formatDateTime, labelForStatus, severityForPriority } from "./utils";
|
|
16
|
+
|
|
17
|
+
type TaskStatusFilter = EthOperationalStatus | "all";
|
|
18
|
+
|
|
19
|
+
const statusFilterOrder: EthOperationalStatus[] = [
|
|
20
|
+
"not-started",
|
|
21
|
+
"queued",
|
|
22
|
+
"in-progress",
|
|
23
|
+
"running",
|
|
24
|
+
"approval-required",
|
|
25
|
+
"pending-approval",
|
|
26
|
+
"blocked",
|
|
27
|
+
"failed",
|
|
28
|
+
"completed",
|
|
29
|
+
"succeeded",
|
|
30
|
+
"synced"
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const priorityLabels: Record<TaskPriority, string> = {
|
|
34
|
+
critical: "Critical",
|
|
35
|
+
high: "High",
|
|
36
|
+
low: "Low",
|
|
37
|
+
medium: "Medium"
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function taskRowsFromItems(items: TaskTableProps["items"]): Task[] {
|
|
41
|
+
return (
|
|
42
|
+
items?.map((item) => ({
|
|
43
|
+
id: item.id,
|
|
44
|
+
title: String(item.label),
|
|
45
|
+
status: item.status ?? "not-started"
|
|
46
|
+
})) ?? []
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function taskTableLabel(title?: React.ReactNode) {
|
|
51
|
+
return typeof title === "string" && title.trim() ? `${title} table` : "Task table";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function priorityCell(priority?: TaskPriority) {
|
|
55
|
+
if (!priority) return <span className="eth-task-table__empty-value">-</span>;
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Badge
|
|
59
|
+
severity={severityForPriority(priority)}
|
|
60
|
+
className={`eth-task-table__priority eth-task-table__priority--${priority}`}
|
|
61
|
+
>
|
|
62
|
+
{priorityLabels[priority]}
|
|
63
|
+
</Badge>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function emptyCell(value?: React.ReactNode) {
|
|
68
|
+
if (value === undefined || value === null || value === "") {
|
|
69
|
+
return <span className="eth-task-table__empty-value">-</span>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function taskStatusOptions(rows: Task[], activeStatus: TaskStatusFilter): SelectOption[] {
|
|
76
|
+
const counts = new Map<EthOperationalStatus, number>();
|
|
77
|
+
for (const task of rows) {
|
|
78
|
+
counts.set(task.status, (counts.get(task.status) ?? 0) + 1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const activeOperationalStatus = activeStatus === "all" ? null : activeStatus;
|
|
82
|
+
const activeStatusWithoutRows = Boolean(
|
|
83
|
+
activeOperationalStatus && !counts.has(activeOperationalStatus)
|
|
84
|
+
);
|
|
85
|
+
const extraStatuses =
|
|
86
|
+
activeOperationalStatus &&
|
|
87
|
+
activeStatusWithoutRows &&
|
|
88
|
+
!statusFilterOrder.includes(activeOperationalStatus)
|
|
89
|
+
? [activeOperationalStatus]
|
|
90
|
+
: [];
|
|
91
|
+
const sortedStatuses = [
|
|
92
|
+
...statusFilterOrder.filter(
|
|
93
|
+
(status) =>
|
|
94
|
+
counts.has(status) || (activeStatusWithoutRows && status === activeOperationalStatus)
|
|
95
|
+
),
|
|
96
|
+
...Array.from(counts.keys()).filter((status) => !statusFilterOrder.includes(status)),
|
|
97
|
+
...extraStatuses
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
return [
|
|
101
|
+
{ value: "all", label: `All statuses (${rows.length})` },
|
|
102
|
+
...sortedStatuses.map((status) => ({
|
|
103
|
+
value: status,
|
|
104
|
+
label: `${labelForStatus(status)} (${counts.get(status) ?? 0})`
|
|
105
|
+
}))
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const defaultColumns: DataColumn<Task>[] = [
|
|
110
|
+
{ key: "id", header: "ID", width: "5rem", sortable: true },
|
|
111
|
+
{
|
|
112
|
+
key: "title",
|
|
113
|
+
header: "Task",
|
|
114
|
+
width: "18rem",
|
|
115
|
+
sortable: true,
|
|
116
|
+
render: (task) => (
|
|
117
|
+
<span className="eth-task-table__task">
|
|
118
|
+
<strong className="eth-task-table__task-title">{task.title}</strong>
|
|
119
|
+
{task.description ? (
|
|
120
|
+
<span className="eth-task-table__task-description">{task.description}</span>
|
|
121
|
+
) : null}
|
|
122
|
+
</span>
|
|
123
|
+
)
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
key: "status",
|
|
127
|
+
header: "Status",
|
|
128
|
+
width: "12rem",
|
|
129
|
+
sortable: true,
|
|
130
|
+
render: (task) => <TaskStatusBadge status={task.status} />
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
key: "priority",
|
|
134
|
+
header: "Priority",
|
|
135
|
+
width: "8rem",
|
|
136
|
+
sortable: true,
|
|
137
|
+
render: (task) => priorityCell(task.priority)
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
key: "assignee",
|
|
141
|
+
header: "Assignee",
|
|
142
|
+
width: "9rem",
|
|
143
|
+
render: (task) => emptyCell(task.assignee)
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
key: "dueAt",
|
|
147
|
+
header: "Due",
|
|
148
|
+
width: "13rem",
|
|
149
|
+
sortable: true,
|
|
150
|
+
render: (task) => (
|
|
151
|
+
<span className={task.dueAt ? "eth-task-table__date" : "eth-task-table__empty-value"}>
|
|
152
|
+
{formatDateTime(task.dueAt)}
|
|
153
|
+
</span>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
export function TaskTable({
|
|
159
|
+
tasks,
|
|
160
|
+
items,
|
|
161
|
+
density = "default",
|
|
162
|
+
selectable,
|
|
163
|
+
selectedRows,
|
|
164
|
+
onSelectionChange,
|
|
165
|
+
rowActions,
|
|
166
|
+
bulkActions,
|
|
167
|
+
columns,
|
|
168
|
+
showFilters,
|
|
169
|
+
statusFilter,
|
|
170
|
+
onStatusFilterChange,
|
|
171
|
+
className,
|
|
172
|
+
title,
|
|
173
|
+
description,
|
|
174
|
+
actions,
|
|
175
|
+
subtitle: _subtitle,
|
|
176
|
+
eyebrow: _eyebrow,
|
|
177
|
+
status: _status,
|
|
178
|
+
severity: _severity,
|
|
179
|
+
loading: _loading,
|
|
180
|
+
empty: _empty,
|
|
181
|
+
error: _error,
|
|
182
|
+
metadata: _metadata,
|
|
183
|
+
footer: _footer,
|
|
184
|
+
"aria-label": ariaLabel,
|
|
185
|
+
...sectionProps
|
|
186
|
+
}: TaskTableProps) {
|
|
187
|
+
const rows = tasks ?? taskRowsFromItems(items);
|
|
188
|
+
const [internalStatusFilter, setInternalStatusFilter] =
|
|
189
|
+
React.useState<TaskStatusFilter>("all");
|
|
190
|
+
const effectiveStatusFilter = statusFilter ?? internalStatusFilter;
|
|
191
|
+
const filterVisible =
|
|
192
|
+
Boolean(showFilters) || statusFilter !== undefined || Boolean(onStatusFilterChange);
|
|
193
|
+
const filteredRows =
|
|
194
|
+
filterVisible && effectiveStatusFilter !== "all"
|
|
195
|
+
? rows.filter((task) => task.status === effectiveStatusFilter)
|
|
196
|
+
: rows;
|
|
197
|
+
const statusOptions = React.useMemo(
|
|
198
|
+
() => taskStatusOptions(rows, effectiveStatusFilter),
|
|
199
|
+
[effectiveStatusFilter, rows]
|
|
200
|
+
);
|
|
201
|
+
const resolvedColumns = columns ?? defaultColumns;
|
|
202
|
+
const label = ariaLabel ?? taskTableLabel(title);
|
|
203
|
+
const emptyTitle = rows.length && filterVisible ? "No matching tasks" : (title ?? "No tasks");
|
|
204
|
+
const emptyDescription =
|
|
205
|
+
rows.length && filterVisible
|
|
206
|
+
? "No tasks match the selected status filter."
|
|
207
|
+
: (description ?? "There are no tasks to display.");
|
|
208
|
+
const selectEnabled = selectable ?? Boolean(bulkActions?.length);
|
|
209
|
+
const handleStatusFilterChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
|
210
|
+
const nextStatus = event.currentTarget.value as TaskStatusFilter;
|
|
211
|
+
if (statusFilter === undefined) setInternalStatusFilter(nextStatus);
|
|
212
|
+
onStatusFilterChange?.(nextStatus);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const table = (
|
|
216
|
+
bulkActions?.length ? (
|
|
217
|
+
<BulkActionTable<Task>
|
|
218
|
+
className={clsx("eth-task-table", className)}
|
|
219
|
+
rows={filteredRows}
|
|
220
|
+
columns={resolvedColumns}
|
|
221
|
+
rowKey="id"
|
|
222
|
+
density={density}
|
|
223
|
+
selectable={selectEnabled}
|
|
224
|
+
selectedRows={selectedRows}
|
|
225
|
+
onSelectionChange={onSelectionChange}
|
|
226
|
+
rowActions={rowActions}
|
|
227
|
+
bulkActions={bulkActions}
|
|
228
|
+
emptyState={<EmptyState title={emptyTitle} description={emptyDescription} />}
|
|
229
|
+
aria-label={label}
|
|
230
|
+
data-eth-component="TaskTable"
|
|
231
|
+
/>
|
|
232
|
+
) : (
|
|
233
|
+
<DataTable<Task>
|
|
234
|
+
className={clsx("eth-task-table", className)}
|
|
235
|
+
rows={filteredRows}
|
|
236
|
+
columns={resolvedColumns}
|
|
237
|
+
rowKey="id"
|
|
238
|
+
density={density}
|
|
239
|
+
selectable={selectable}
|
|
240
|
+
selectedRows={selectedRows}
|
|
241
|
+
onSelectionChange={onSelectionChange}
|
|
242
|
+
rowActions={rowActions}
|
|
243
|
+
emptyState={<EmptyState title={emptyTitle} description={emptyDescription} />}
|
|
244
|
+
aria-label={label}
|
|
245
|
+
data-eth-component="TaskTable"
|
|
246
|
+
/>
|
|
247
|
+
)
|
|
248
|
+
);
|
|
249
|
+
const hasHeader = Boolean(title || description || actions?.length);
|
|
250
|
+
|
|
251
|
+
if (!hasHeader && !filterVisible) return table;
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
<section
|
|
255
|
+
{...sectionProps}
|
|
256
|
+
className="eth-task-table-shell"
|
|
257
|
+
aria-label={typeof title === "string" ? title : "Tasks"}
|
|
258
|
+
>
|
|
259
|
+
{hasHeader ? (
|
|
260
|
+
<header className="eth-task-table-shell__header">
|
|
261
|
+
<div className="eth-task-table-shell__heading">
|
|
262
|
+
{title ? <h2>{title}</h2> : null}
|
|
263
|
+
{description ? <p>{description}</p> : null}
|
|
264
|
+
</div>
|
|
265
|
+
<ActionGroup actions={actions} />
|
|
266
|
+
</header>
|
|
267
|
+
) : null}
|
|
268
|
+
{filterVisible ? (
|
|
269
|
+
<div className="eth-task-table-shell__toolbar" aria-label="Task table filters">
|
|
270
|
+
<div className="eth-task-table-shell__filters">
|
|
271
|
+
<Select
|
|
272
|
+
className="eth-task-table-shell__filter"
|
|
273
|
+
density="compact"
|
|
274
|
+
labelText="Status"
|
|
275
|
+
options={statusOptions}
|
|
276
|
+
value={effectiveStatusFilter}
|
|
277
|
+
onChange={handleStatusFilterChange}
|
|
278
|
+
/>
|
|
279
|
+
</div>
|
|
280
|
+
<span className="eth-task-table-shell__summary">
|
|
281
|
+
{filteredRows.length === rows.length
|
|
282
|
+
? `${rows.length} ${rows.length === 1 ? "task" : "tasks"} shown`
|
|
283
|
+
: `${filteredRows.length} of ${rows.length} tasks shown`}
|
|
284
|
+
</span>
|
|
285
|
+
</div>
|
|
286
|
+
) : null}
|
|
287
|
+
{table}
|
|
288
|
+
</section>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function StatusCell({ task }: { task: Task }) {
|
|
293
|
+
return <StatusDot status={task.status} label={labelForStatus(task.status)} />;
|
|
294
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { Badge, EmptyState, type EthSeverity } from "@echothink-ui/core";
|
|
4
|
+
import type { TaskTimelineEvent, TaskTimelineProps } from "../types";
|
|
5
|
+
import { formatDateTime } from "./utils";
|
|
6
|
+
|
|
7
|
+
const eventKindMeta: Record<TaskTimelineEvent["kind"], { label: string; tone: EthSeverity }> = {
|
|
8
|
+
status_change: { label: "Status", tone: "info" },
|
|
9
|
+
comment: { label: "Comment", tone: "neutral" },
|
|
10
|
+
assignment: { label: "Assignment", tone: "info" },
|
|
11
|
+
attachment: { label: "Attachment", tone: "success" },
|
|
12
|
+
system: { label: "System", tone: "neutral" }
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function timestampMeta(value: string | Date) {
|
|
16
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
17
|
+
if (Number.isNaN(date.valueOf())) {
|
|
18
|
+
const fallback = String(value);
|
|
19
|
+
return { dateTime: fallback, label: fallback };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
dateTime: date.toISOString(),
|
|
24
|
+
label: formatDateTime(date)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function TaskTimeline({
|
|
29
|
+
events = [],
|
|
30
|
+
className,
|
|
31
|
+
role,
|
|
32
|
+
"aria-label": ariaLabel,
|
|
33
|
+
...props
|
|
34
|
+
}: TaskTimelineProps) {
|
|
35
|
+
if (!events.length) {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
{...props}
|
|
39
|
+
className={clsx("eth-task-timeline", className)}
|
|
40
|
+
data-eth-component="TaskTimeline"
|
|
41
|
+
role={role ?? "region"}
|
|
42
|
+
aria-label={ariaLabel ?? "Task timeline"}
|
|
43
|
+
>
|
|
44
|
+
<EmptyState
|
|
45
|
+
title="No task history"
|
|
46
|
+
description="Task activity and state changes appear here."
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div
|
|
54
|
+
{...props}
|
|
55
|
+
className={clsx("eth-task-timeline", className)}
|
|
56
|
+
data-eth-component="TaskTimeline"
|
|
57
|
+
role={role ?? "region"}
|
|
58
|
+
aria-label={ariaLabel ?? "Task timeline"}
|
|
59
|
+
>
|
|
60
|
+
<ol className="eth-task-timeline__list">
|
|
61
|
+
{events.map((event) => {
|
|
62
|
+
const kind = eventKindMeta[event.kind];
|
|
63
|
+
const time = timestampMeta(event.timestamp);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<li
|
|
67
|
+
key={event.id}
|
|
68
|
+
className={clsx(
|
|
69
|
+
"eth-task-timeline__event",
|
|
70
|
+
`eth-task-timeline__event--${event.kind}`
|
|
71
|
+
)}
|
|
72
|
+
data-kind={event.kind}
|
|
73
|
+
>
|
|
74
|
+
<time className="eth-task-timeline__time" dateTime={time.dateTime}>
|
|
75
|
+
{time.label}
|
|
76
|
+
</time>
|
|
77
|
+
<div className="eth-task-timeline__rail" aria-hidden>
|
|
78
|
+
<span className="eth-task-timeline__marker" />
|
|
79
|
+
</div>
|
|
80
|
+
<article className="eth-task-timeline__body">
|
|
81
|
+
<header className="eth-task-timeline__event-header">
|
|
82
|
+
<Badge severity={kind.tone}>{kind.label}</Badge>
|
|
83
|
+
{event.actor ? (
|
|
84
|
+
<span className="eth-task-timeline__actor">By {event.actor}</span>
|
|
85
|
+
) : null}
|
|
86
|
+
</header>
|
|
87
|
+
<strong className="eth-task-timeline__summary">{event.summary}</strong>
|
|
88
|
+
{event.details ? (
|
|
89
|
+
<p className="eth-task-timeline__details">{event.details}</p>
|
|
90
|
+
) : null}
|
|
91
|
+
</article>
|
|
92
|
+
</li>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</ol>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { EmptyState, Surface } from "@echothink-ui/core";
|
|
4
|
+
import type {
|
|
5
|
+
DAGEdgeModel,
|
|
6
|
+
DAGNodeModel,
|
|
7
|
+
PositionedDAGNode,
|
|
8
|
+
TaskWaveDAGProps
|
|
9
|
+
} from "../types";
|
|
10
|
+
import { DAGEdge } from "./DAGEdge";
|
|
11
|
+
import { DAGLegend } from "./DAGLegend";
|
|
12
|
+
import { DAGNode, DAG_NODE_HEIGHT, DAG_NODE_WIDTH } from "./DAGNode";
|
|
13
|
+
|
|
14
|
+
const rankSpacing = DAG_NODE_WIDTH + 76;
|
|
15
|
+
const rowSpacing = DAG_NODE_HEIGHT + 48;
|
|
16
|
+
const originX = DAG_NODE_WIDTH / 2 + 28;
|
|
17
|
+
const originY = DAG_NODE_HEIGHT / 2 + 48;
|
|
18
|
+
const framePaddingX = 32;
|
|
19
|
+
const framePaddingY = 28;
|
|
20
|
+
|
|
21
|
+
interface DAGFrame {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
viewBox: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function layoutNodes(
|
|
30
|
+
nodes: DAGNodeModel[],
|
|
31
|
+
edges: DAGEdgeModel[],
|
|
32
|
+
layout: "lr" | "tb"
|
|
33
|
+
): PositionedDAGNode[] {
|
|
34
|
+
const ids = new Set(nodes.map((node) => node.id));
|
|
35
|
+
const ranks = new Map(nodes.map((node) => [node.id, 0]));
|
|
36
|
+
const validEdges = edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to));
|
|
37
|
+
|
|
38
|
+
for (let pass = 0; pass < nodes.length; pass += 1) {
|
|
39
|
+
for (const edge of validEdges) {
|
|
40
|
+
const fromRank = ranks.get(edge.from) ?? 0;
|
|
41
|
+
const toRank = ranks.get(edge.to) ?? 0;
|
|
42
|
+
if (toRank < fromRank + 1) ranks.set(edge.to, fromRank + 1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const groups = new Map<number, DAGNodeModel[]>();
|
|
47
|
+
for (const node of nodes) {
|
|
48
|
+
const rank = ranks.get(node.id) ?? 0;
|
|
49
|
+
groups.set(rank, [...(groups.get(rank) ?? []), node]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return nodes.map((node) => {
|
|
53
|
+
if (typeof node.x === "number" && typeof node.y === "number") {
|
|
54
|
+
return { ...node, x: node.x, y: node.y };
|
|
55
|
+
}
|
|
56
|
+
const rank = ranks.get(node.id) ?? 0;
|
|
57
|
+
const group = groups.get(rank) ?? [];
|
|
58
|
+
const index = group.findIndex((candidate) => candidate.id === node.id);
|
|
59
|
+
const spread = Math.max(0, index) * rowSpacing + originY;
|
|
60
|
+
const ranked = rank * rankSpacing + originX;
|
|
61
|
+
return layout === "lr"
|
|
62
|
+
? { ...node, x: ranked, y: spread }
|
|
63
|
+
: { ...node, x: spread, y: ranked };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function frameFor(nodes: PositionedDAGNode[]): DAGFrame {
|
|
68
|
+
if (!nodes.length) {
|
|
69
|
+
return { x: 0, y: 0, width: 640, height: 180, viewBox: "0 0 640 180" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const minX =
|
|
73
|
+
Math.min(...nodes.map((node) => node.x - DAG_NODE_WIDTH / 2)) - framePaddingX;
|
|
74
|
+
const minY =
|
|
75
|
+
Math.min(...nodes.map((node) => node.y - DAG_NODE_HEIGHT / 2)) - framePaddingY;
|
|
76
|
+
const maxX =
|
|
77
|
+
Math.max(...nodes.map((node) => node.x + DAG_NODE_WIDTH / 2)) + framePaddingX;
|
|
78
|
+
const maxY =
|
|
79
|
+
Math.max(...nodes.map((node) => node.y + DAG_NODE_HEIGHT / 2)) + framePaddingY;
|
|
80
|
+
const width = Math.max(360, maxX - minX);
|
|
81
|
+
const height = Math.max(120, maxY - minY);
|
|
82
|
+
|
|
83
|
+
return { x: minX, y: minY, width, height, viewBox: `${minX} ${minY} ${width} ${height}` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function edgeEndpoints(
|
|
87
|
+
from: PositionedDAGNode,
|
|
88
|
+
to: PositionedDAGNode,
|
|
89
|
+
layout: "lr" | "tb"
|
|
90
|
+
) {
|
|
91
|
+
if (layout === "lr") {
|
|
92
|
+
return {
|
|
93
|
+
from: { x: from.x + DAG_NODE_WIDTH / 2, y: from.y },
|
|
94
|
+
to: { x: to.x - DAG_NODE_WIDTH / 2, y: to.y }
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
from: { x: from.x, y: from.y + DAG_NODE_HEIGHT / 2 },
|
|
99
|
+
to: { x: to.x, y: to.y - DAG_NODE_HEIGHT / 2 }
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function TaskWaveDAG({
|
|
104
|
+
nodes = [],
|
|
105
|
+
edges = [],
|
|
106
|
+
layout = "lr",
|
|
107
|
+
onNodeSelect,
|
|
108
|
+
selectedNodeId,
|
|
109
|
+
className,
|
|
110
|
+
title,
|
|
111
|
+
description,
|
|
112
|
+
...props
|
|
113
|
+
}: TaskWaveDAGProps) {
|
|
114
|
+
const positionedNodes = React.useMemo(
|
|
115
|
+
() => layoutNodes(nodes, edges, layout),
|
|
116
|
+
[nodes, edges, layout]
|
|
117
|
+
);
|
|
118
|
+
const nodeMap = React.useMemo(
|
|
119
|
+
() => new Map(positionedNodes.map((node) => [node.id, node])),
|
|
120
|
+
[positionedNodes]
|
|
121
|
+
);
|
|
122
|
+
const visibleStatuses = React.useMemo(
|
|
123
|
+
() => Array.from(new Set(positionedNodes.map((node) => node.status))),
|
|
124
|
+
[positionedNodes]
|
|
125
|
+
);
|
|
126
|
+
const graphFrame = React.useMemo(() => frameFor(positionedNodes), [positionedNodes]);
|
|
127
|
+
const graphTitleId = React.useId();
|
|
128
|
+
const graphDescriptionId = React.useId();
|
|
129
|
+
const graphTitle =
|
|
130
|
+
typeof title === "string" && title.trim() ? title : "Task wave dependency graph";
|
|
131
|
+
const graphDescription = `${positionedNodes.length} nodes and ${edges.length} dependencies. Statuses: ${
|
|
132
|
+
visibleStatuses.map((status) => status.replace(/-/g, " ")).join(", ") || "none"
|
|
133
|
+
}.`;
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<Surface
|
|
137
|
+
{...props}
|
|
138
|
+
className={clsx("eth-task-wave-dag", className)}
|
|
139
|
+
title={title ?? "Wave DAG"}
|
|
140
|
+
description={description}
|
|
141
|
+
data-eth-component="TaskWaveDAG"
|
|
142
|
+
>
|
|
143
|
+
{positionedNodes.length ? (
|
|
144
|
+
<>
|
|
145
|
+
<div className="eth-task-wave-dag__viewport">
|
|
146
|
+
<svg
|
|
147
|
+
className="eth-dag"
|
|
148
|
+
role="img"
|
|
149
|
+
aria-labelledby={graphTitleId}
|
|
150
|
+
aria-describedby={graphDescriptionId}
|
|
151
|
+
viewBox={graphFrame.viewBox}
|
|
152
|
+
>
|
|
153
|
+
<title id={graphTitleId}>{graphTitle}</title>
|
|
154
|
+
<desc id={graphDescriptionId}>{graphDescription}</desc>
|
|
155
|
+
<rect
|
|
156
|
+
className="eth-dag__canvas"
|
|
157
|
+
x={graphFrame.x}
|
|
158
|
+
y={graphFrame.y}
|
|
159
|
+
width={graphFrame.width}
|
|
160
|
+
height={graphFrame.height}
|
|
161
|
+
aria-hidden="true"
|
|
162
|
+
/>
|
|
163
|
+
{edges.map((edge, index) => {
|
|
164
|
+
const from = nodeMap.get(edge.from);
|
|
165
|
+
const to = nodeMap.get(edge.to);
|
|
166
|
+
if (!from || !to) return null;
|
|
167
|
+
const endpoints = edgeEndpoints(from, to, layout);
|
|
168
|
+
return (
|
|
169
|
+
<DAGEdge
|
|
170
|
+
key={`${edge.from}-${edge.to}-${index}`}
|
|
171
|
+
from={endpoints.from}
|
|
172
|
+
to={endpoints.to}
|
|
173
|
+
status={edge.status ?? to.status}
|
|
174
|
+
label={edge.label}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
{positionedNodes.map((node) => (
|
|
179
|
+
<DAGNode
|
|
180
|
+
key={node.id}
|
|
181
|
+
node={node}
|
|
182
|
+
selected={node.id === selectedNodeId}
|
|
183
|
+
onSelect={onNodeSelect}
|
|
184
|
+
/>
|
|
185
|
+
))}
|
|
186
|
+
</svg>
|
|
187
|
+
</div>
|
|
188
|
+
<DAGLegend statuses={visibleStatuses.length ? visibleStatuses : undefined} />
|
|
189
|
+
<ol className="eth-task-wave-dag__fallback">
|
|
190
|
+
{positionedNodes.map((node) => (
|
|
191
|
+
<li key={node.id}>
|
|
192
|
+
{node.label}, {node.status.replace(/-/g, " ")}
|
|
193
|
+
</li>
|
|
194
|
+
))}
|
|
195
|
+
</ol>
|
|
196
|
+
</>
|
|
197
|
+
) : (
|
|
198
|
+
<EmptyState title="No DAG nodes" description="Add nodes and edges to render a wave graph." />
|
|
199
|
+
)}
|
|
200
|
+
</Surface>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { Badge, Surface } from "@echothink-ui/core";
|
|
4
|
+
import type { EthOperationalStatus } from "@echothink-ui/core";
|
|
5
|
+
import type { TaskWaveHeaderProps } from "../types";
|
|
6
|
+
import { TaskProgressIndicator } from "./TaskProgressIndicator";
|
|
7
|
+
import {
|
|
8
|
+
formatDateTime,
|
|
9
|
+
labelForStatus,
|
|
10
|
+
severityForStatus,
|
|
11
|
+
waveProgress
|
|
12
|
+
} from "./utils";
|
|
13
|
+
|
|
14
|
+
const statusOrder: EthOperationalStatus[] = [
|
|
15
|
+
"queued",
|
|
16
|
+
"running",
|
|
17
|
+
"in-progress",
|
|
18
|
+
"pending-approval",
|
|
19
|
+
"approval-required",
|
|
20
|
+
"blocked",
|
|
21
|
+
"failed",
|
|
22
|
+
"completed",
|
|
23
|
+
"succeeded"
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function TaskWaveHeader({
|
|
27
|
+
wave,
|
|
28
|
+
actions,
|
|
29
|
+
className,
|
|
30
|
+
title,
|
|
31
|
+
description,
|
|
32
|
+
...props
|
|
33
|
+
}: TaskWaveHeaderProps) {
|
|
34
|
+
const progress = waveProgress(wave);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Surface
|
|
38
|
+
{...props}
|
|
39
|
+
className={clsx("eth-task-wave-header", className)}
|
|
40
|
+
title={wave?.label ?? title ?? "Task wave"}
|
|
41
|
+
subtitle={wave?.id}
|
|
42
|
+
description={description}
|
|
43
|
+
actions={actions}
|
|
44
|
+
data-eth-component="TaskWaveHeader"
|
|
45
|
+
>
|
|
46
|
+
{wave ? (
|
|
47
|
+
<>
|
|
48
|
+
<div className="eth-task-wave-header__summary">
|
|
49
|
+
<strong>{wave.totalTasks} tasks</strong>
|
|
50
|
+
{wave.startedAt ? <span>Started {formatDateTime(wave.startedAt)}</span> : null}
|
|
51
|
+
{wave.eta ? <span>ETA {formatDateTime(wave.eta)}</span> : null}
|
|
52
|
+
</div>
|
|
53
|
+
<TaskProgressIndicator value={progress} label="Wave progress" />
|
|
54
|
+
<div className="eth-task-wave-header__counts" aria-label="Task status counts">
|
|
55
|
+
{statusOrder
|
|
56
|
+
.filter((status) => wave.statuses[status])
|
|
57
|
+
.map((status) => {
|
|
58
|
+
const label = labelForStatus(status);
|
|
59
|
+
const count = wave.statuses[status] ?? 0;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Badge
|
|
63
|
+
key={status}
|
|
64
|
+
severity={severityForStatus(status)}
|
|
65
|
+
className={clsx(
|
|
66
|
+
"eth-task-wave-header__count",
|
|
67
|
+
`eth-task-wave-header__count--${status}`
|
|
68
|
+
)}
|
|
69
|
+
aria-label={`${label}: ${count}`}
|
|
70
|
+
>
|
|
71
|
+
{label}: <span className="eth-task-wave-header__count-value">{count}</span>
|
|
72
|
+
</Badge>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</div>
|
|
76
|
+
</>
|
|
77
|
+
) : (
|
|
78
|
+
<TaskProgressIndicator value={0} label="Wave progress" />
|
|
79
|
+
)}
|
|
80
|
+
</Surface>
|
|
81
|
+
);
|
|
82
|
+
}
|