@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
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@echothink-ui/task",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": [
|
|
7
|
+
"**/*.css"
|
|
8
|
+
],
|
|
9
|
+
"main": "./dist/index.cjs",
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"require": "./dist/index.cjs"
|
|
17
|
+
},
|
|
18
|
+
"./styles.css": "./src/styles.css"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": ">=18.3.0",
|
|
27
|
+
"react-dom": ">=18.3.0"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"clsx": "^2.1.1",
|
|
31
|
+
"@echothink-ui/core": "0.2.0",
|
|
32
|
+
"@echothink-ui/icons": "0.2.0",
|
|
33
|
+
"@echothink-ui/data": "0.2.0",
|
|
34
|
+
"@echothink-ui/motion": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsup src/index.tsx --format esm,cjs --sourcemap --clean --external react --external react-dom && tsc -p tsconfig.json --declaration --emitDeclarationOnly --noEmit false --outDir dist",
|
|
41
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
42
|
+
"test": "vitest run --config ../../vitest.config.ts --passWithNoTests",
|
|
43
|
+
"lint": "eslint src"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { Badge, EmptyState, StatusDot, Tag } from "@echothink-ui/core";
|
|
4
|
+
import type { BackendThinkingStep, BackendThinkingChainProps } from "../types";
|
|
5
|
+
import { formatDuration, labelForStatus } from "./utils";
|
|
6
|
+
|
|
7
|
+
const STREAMING_STATUSES = new Set<BackendThinkingStep["status"]>(["running", "in-progress"]);
|
|
8
|
+
|
|
9
|
+
function flattenSteps(steps: BackendThinkingStep[]): BackendThinkingStep[] {
|
|
10
|
+
return steps.flatMap((step) => [step, ...flattenSteps(step.children ?? [])]);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function currentStreamingStepId(steps: BackendThinkingStep[]) {
|
|
14
|
+
const flattened = flattenSteps(steps);
|
|
15
|
+
|
|
16
|
+
for (let index = flattened.length - 1; index >= 0; index -= 1) {
|
|
17
|
+
if (STREAMING_STATUSES.has(flattened[index].status)) {
|
|
18
|
+
return flattened[index].id;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return flattened.at(-1)?.id;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ThinkingStepItem({
|
|
26
|
+
step,
|
|
27
|
+
streamingStepId
|
|
28
|
+
}: {
|
|
29
|
+
step: BackendThinkingStep;
|
|
30
|
+
streamingStepId?: string;
|
|
31
|
+
}) {
|
|
32
|
+
const isStreaming = streamingStepId === step.id;
|
|
33
|
+
const hasChildren = Boolean(step.children?.length);
|
|
34
|
+
const [expanded, setExpanded] = React.useState(true);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<li
|
|
38
|
+
className={clsx(
|
|
39
|
+
"eth-task-thinking-chain__item",
|
|
40
|
+
`eth-task-thinking-chain__item--${step.status}`,
|
|
41
|
+
step.redacted && "eth-task-thinking-chain__item--redacted"
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
<span className="eth-task-thinking-chain__marker" aria-hidden="true" />
|
|
45
|
+
<details
|
|
46
|
+
className="eth-task-thinking-chain__step"
|
|
47
|
+
open={expanded}
|
|
48
|
+
onToggle={(event) => {
|
|
49
|
+
setExpanded(event.currentTarget.open);
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
<summary className="eth-task-thinking-chain__step-header">
|
|
53
|
+
<span className="eth-task-thinking-chain__disclosure-icon" aria-hidden="true" />
|
|
54
|
+
<span className="eth-task-thinking-chain__step-header-inner">
|
|
55
|
+
<span className="eth-task-thinking-chain__step-heading">
|
|
56
|
+
<strong className="eth-task-thinking-chain__step-title">
|
|
57
|
+
{step.redacted ? "Restricted step" : step.title}
|
|
58
|
+
</strong>
|
|
59
|
+
{hasChildren ? (
|
|
60
|
+
<span className="eth-task-thinking-chain__branch-count">
|
|
61
|
+
{step.children?.length} {step.children?.length === 1 ? "substep" : "substeps"}
|
|
62
|
+
</span>
|
|
63
|
+
) : null}
|
|
64
|
+
{isStreaming ? (
|
|
65
|
+
<span className="eth-task-thinking-chain__streaming" aria-label="Streaming update">
|
|
66
|
+
<span className="eth-task-thinking-chain__pulse" aria-hidden="true" />
|
|
67
|
+
Streaming
|
|
68
|
+
</span>
|
|
69
|
+
) : null}
|
|
70
|
+
</span>
|
|
71
|
+
<span className="eth-task-thinking-chain__step-meta">
|
|
72
|
+
{step.redacted ? <Badge severity="warning">Redacted</Badge> : null}
|
|
73
|
+
<StatusDot status={step.status} label={labelForStatus(step.status)} />
|
|
74
|
+
{step.toolName ? <Tag>{step.toolName}</Tag> : null}
|
|
75
|
+
{step.durationMs != null ? (
|
|
76
|
+
<span className="eth-task-thinking-chain__duration">
|
|
77
|
+
{formatDuration(step.durationMs)}
|
|
78
|
+
</span>
|
|
79
|
+
) : null}
|
|
80
|
+
</span>
|
|
81
|
+
</span>
|
|
82
|
+
</summary>
|
|
83
|
+
{step.redacted ? (
|
|
84
|
+
<p className="eth-task-thinking-chain__redacted">Details are hidden by policy.</p>
|
|
85
|
+
) : step.summary ? (
|
|
86
|
+
<p className="eth-task-thinking-chain__summary">{step.summary}</p>
|
|
87
|
+
) : null}
|
|
88
|
+
{hasChildren ? (
|
|
89
|
+
<ol className="eth-task-thinking-chain__children">
|
|
90
|
+
{step.children?.map((child) => (
|
|
91
|
+
<ThinkingStepItem key={child.id} step={child} streamingStepId={streamingStepId} />
|
|
92
|
+
))}
|
|
93
|
+
</ol>
|
|
94
|
+
) : null}
|
|
95
|
+
</details>
|
|
96
|
+
</li>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function BackendThinkingChain({
|
|
101
|
+
steps = [],
|
|
102
|
+
streaming,
|
|
103
|
+
className,
|
|
104
|
+
...props
|
|
105
|
+
}: BackendThinkingChainProps) {
|
|
106
|
+
const streamingStepId = React.useMemo(
|
|
107
|
+
() => (streaming ? currentStreamingStepId(steps) : undefined),
|
|
108
|
+
[steps, streaming]
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<section
|
|
113
|
+
{...props}
|
|
114
|
+
className={clsx("eth-task-thinking-chain", className)}
|
|
115
|
+
aria-live={streaming ? "polite" : undefined}
|
|
116
|
+
data-eth-component="BackendThinkingChain"
|
|
117
|
+
>
|
|
118
|
+
{steps.length ? (
|
|
119
|
+
<ol className="eth-task-thinking-chain__list">
|
|
120
|
+
{steps.map((step) => (
|
|
121
|
+
<ThinkingStepItem key={step.id} step={step} streamingStepId={streamingStepId} />
|
|
122
|
+
))}
|
|
123
|
+
</ol>
|
|
124
|
+
) : (
|
|
125
|
+
<EmptyState title="No backend steps" />
|
|
126
|
+
)}
|
|
127
|
+
</section>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { ActionGroup, Badge, EmptyState, Surface } from "@echothink-ui/core";
|
|
4
|
+
import type { BlockingReasonPanelProps } from "../types";
|
|
5
|
+
|
|
6
|
+
export function BlockingReasonPanel({
|
|
7
|
+
blockers = [],
|
|
8
|
+
className,
|
|
9
|
+
title = "Blocking reasons",
|
|
10
|
+
description,
|
|
11
|
+
severity,
|
|
12
|
+
status,
|
|
13
|
+
...props
|
|
14
|
+
}: BlockingReasonPanelProps) {
|
|
15
|
+
const hasBlockers = blockers.length > 0;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<Surface
|
|
19
|
+
{...props}
|
|
20
|
+
className={clsx("eth-task-blocking-reason-panel", className)}
|
|
21
|
+
title={title}
|
|
22
|
+
description={description}
|
|
23
|
+
status={status ?? (hasBlockers ? "blocked" : undefined)}
|
|
24
|
+
severity={severity ?? (hasBlockers ? "danger" : undefined)}
|
|
25
|
+
data-eth-component="BlockingReasonPanel"
|
|
26
|
+
>
|
|
27
|
+
{hasBlockers ? (
|
|
28
|
+
<ul className="eth-task-blocking-reason-panel__list" aria-label="Active blocking reasons">
|
|
29
|
+
{blockers.map((blocker) => {
|
|
30
|
+
const resolveActions = blocker.resolveAction
|
|
31
|
+
? [
|
|
32
|
+
{
|
|
33
|
+
...blocker.resolveAction,
|
|
34
|
+
intent: blocker.resolveAction.intent ?? "tertiary"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
: undefined;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<li key={blocker.id} className="eth-task-blocking-reason-panel__item">
|
|
41
|
+
<div className="eth-task-blocking-reason-panel__content">
|
|
42
|
+
<strong className="eth-task-blocking-reason-panel__reason">
|
|
43
|
+
{blocker.reason}
|
|
44
|
+
</strong>
|
|
45
|
+
<div className="eth-task-blocking-reason-panel__meta">
|
|
46
|
+
<Badge severity="danger">Blocked</Badge>
|
|
47
|
+
{blocker.ownerActor ? <span>Owner: {blocker.ownerActor}</span> : null}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
{resolveActions ? (
|
|
51
|
+
<div className="eth-task-blocking-reason-panel__actions">
|
|
52
|
+
<ActionGroup actions={resolveActions} />
|
|
53
|
+
</div>
|
|
54
|
+
) : null}
|
|
55
|
+
</li>
|
|
56
|
+
);
|
|
57
|
+
})}
|
|
58
|
+
</ul>
|
|
59
|
+
) : (
|
|
60
|
+
<EmptyState
|
|
61
|
+
title="No blocking reasons"
|
|
62
|
+
description="This task can continue without unblock actions."
|
|
63
|
+
/>
|
|
64
|
+
)}
|
|
65
|
+
</Surface>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import type { DAGEdgeProps } from "../types";
|
|
4
|
+
import { labelForStatus, statusColor } from "./utils";
|
|
5
|
+
|
|
6
|
+
function markerIdFromUseId(id: string) {
|
|
7
|
+
return `eth-dag-arrow-${id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function DAGEdge({
|
|
11
|
+
from,
|
|
12
|
+
to,
|
|
13
|
+
status,
|
|
14
|
+
label,
|
|
15
|
+
className,
|
|
16
|
+
"aria-label": ariaLabel,
|
|
17
|
+
...props
|
|
18
|
+
}: DAGEdgeProps) {
|
|
19
|
+
const reactId = React.useId();
|
|
20
|
+
const markerId = markerIdFromUseId(reactId);
|
|
21
|
+
const horizontal = Math.abs(to.x - from.x) >= Math.abs(to.y - from.y);
|
|
22
|
+
const controlA = horizontal
|
|
23
|
+
? { x: (from.x + to.x) / 2, y: from.y }
|
|
24
|
+
: { x: from.x, y: (from.y + to.y) / 2 };
|
|
25
|
+
const controlB = horizontal
|
|
26
|
+
? { x: (from.x + to.x) / 2, y: to.y }
|
|
27
|
+
: { x: to.x, y: (from.y + to.y) / 2 };
|
|
28
|
+
const d = `M ${from.x} ${from.y} C ${controlA.x} ${controlA.y}, ${controlB.x} ${controlB.y}, ${to.x} ${to.y}`;
|
|
29
|
+
const color = statusColor(status);
|
|
30
|
+
const midpoint = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };
|
|
31
|
+
const labelText = label?.trim();
|
|
32
|
+
const labelWidth = labelText ? Math.max(56, labelText.length * 7 + 20) : 0;
|
|
33
|
+
const labelPosition = horizontal
|
|
34
|
+
? { x: midpoint.x, y: midpoint.y - 16 }
|
|
35
|
+
: { x: midpoint.x + 44, y: midpoint.y };
|
|
36
|
+
const statusText = status ? labelForStatus(status) : "Neutral";
|
|
37
|
+
const accessibleLabel =
|
|
38
|
+
typeof ariaLabel === "string" && ariaLabel.trim()
|
|
39
|
+
? ariaLabel
|
|
40
|
+
: labelText
|
|
41
|
+
? `${labelText} dependency edge, ${statusText}`
|
|
42
|
+
: `Dependency edge, ${statusText}`;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<g
|
|
46
|
+
className={clsx("eth-dag-edge", status && `eth-dag-edge--${status}`, className)}
|
|
47
|
+
role="group"
|
|
48
|
+
aria-label={accessibleLabel}
|
|
49
|
+
>
|
|
50
|
+
<defs>
|
|
51
|
+
<marker
|
|
52
|
+
id={markerId}
|
|
53
|
+
markerWidth="10"
|
|
54
|
+
markerHeight="10"
|
|
55
|
+
refX="9"
|
|
56
|
+
refY="3"
|
|
57
|
+
orient="auto"
|
|
58
|
+
markerUnits="strokeWidth"
|
|
59
|
+
>
|
|
60
|
+
<path d="M0,0 L0,6 L9,3 z" fill={color} />
|
|
61
|
+
</marker>
|
|
62
|
+
</defs>
|
|
63
|
+
<path
|
|
64
|
+
{...props}
|
|
65
|
+
d={d}
|
|
66
|
+
fill="none"
|
|
67
|
+
stroke={color}
|
|
68
|
+
strokeLinecap="round"
|
|
69
|
+
strokeLinejoin="round"
|
|
70
|
+
strokeWidth={2}
|
|
71
|
+
markerEnd={`url(#${markerId})`}
|
|
72
|
+
vectorEffect="non-scaling-stroke"
|
|
73
|
+
aria-hidden="true"
|
|
74
|
+
data-eth-component="DAGEdge"
|
|
75
|
+
/>
|
|
76
|
+
{labelText ? (
|
|
77
|
+
<g className="eth-dag-edge__label" aria-hidden="true">
|
|
78
|
+
<rect
|
|
79
|
+
x={labelPosition.x - labelWidth / 2}
|
|
80
|
+
y={labelPosition.y - 11}
|
|
81
|
+
width={labelWidth}
|
|
82
|
+
height={22}
|
|
83
|
+
rx={0}
|
|
84
|
+
/>
|
|
85
|
+
<text
|
|
86
|
+
x={labelPosition.x}
|
|
87
|
+
y={labelPosition.y}
|
|
88
|
+
textAnchor="middle"
|
|
89
|
+
dominantBaseline="middle"
|
|
90
|
+
>
|
|
91
|
+
{labelText}
|
|
92
|
+
</text>
|
|
93
|
+
</g>
|
|
94
|
+
) : null}
|
|
95
|
+
</g>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import type { EthOperationalStatus } from "@echothink-ui/core";
|
|
4
|
+
import type { DAGEdgeLegendItem, DAGLegendProps } from "../types";
|
|
5
|
+
import { labelForStatus, statusColor } from "./utils";
|
|
6
|
+
|
|
7
|
+
const defaultStatuses: EthOperationalStatus[] = [
|
|
8
|
+
"not-started",
|
|
9
|
+
"queued",
|
|
10
|
+
"running",
|
|
11
|
+
"blocked",
|
|
12
|
+
"failed",
|
|
13
|
+
"pending-approval",
|
|
14
|
+
"completed"
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const defaultEdgeTypes: DAGEdgeLegendItem[] = [
|
|
18
|
+
{ id: "dependency", label: "Dependency", variant: "dependency" },
|
|
19
|
+
{ id: "critical-path", label: "Critical path", status: "running", variant: "critical" },
|
|
20
|
+
{
|
|
21
|
+
id: "approval-gate",
|
|
22
|
+
label: "Approval gate",
|
|
23
|
+
status: "pending-approval",
|
|
24
|
+
variant: "conditional"
|
|
25
|
+
}
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function colorVariable(color: string) {
|
|
29
|
+
return { "--eth-dag-legend-color": color } as React.CSSProperties;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function DAGLegend({
|
|
33
|
+
statuses = defaultStatuses,
|
|
34
|
+
edgeTypes = defaultEdgeTypes,
|
|
35
|
+
className,
|
|
36
|
+
"aria-label": ariaLabel,
|
|
37
|
+
...props
|
|
38
|
+
}: DAGLegendProps) {
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
{...props}
|
|
42
|
+
className={clsx("eth-dag-legend", className)}
|
|
43
|
+
data-eth-component="DAGLegend"
|
|
44
|
+
role="group"
|
|
45
|
+
aria-label={ariaLabel ?? "DAG legend"}
|
|
46
|
+
>
|
|
47
|
+
{statuses.length ? (
|
|
48
|
+
<div className="eth-dag-legend__group">
|
|
49
|
+
<span className="eth-dag-legend__heading">Node status</span>
|
|
50
|
+
<ul className="eth-dag-legend__list" aria-label="DAG node status legend">
|
|
51
|
+
{statuses.map((status) => (
|
|
52
|
+
<li key={status} className="eth-dag-legend__item">
|
|
53
|
+
<span
|
|
54
|
+
className={clsx("eth-dag-legend__swatch", `eth-dag-legend__swatch--${status}`)}
|
|
55
|
+
style={colorVariable(statusColor(status))}
|
|
56
|
+
aria-hidden="true"
|
|
57
|
+
/>
|
|
58
|
+
<span className="eth-dag-legend__label">{labelForStatus(status)}</span>
|
|
59
|
+
</li>
|
|
60
|
+
))}
|
|
61
|
+
</ul>
|
|
62
|
+
</div>
|
|
63
|
+
) : null}
|
|
64
|
+
{edgeTypes.length ? (
|
|
65
|
+
<div className="eth-dag-legend__group eth-dag-legend__group--edges">
|
|
66
|
+
<span className="eth-dag-legend__heading">Edge type</span>
|
|
67
|
+
<ul className="eth-dag-legend__list" aria-label="DAG edge type legend">
|
|
68
|
+
{edgeTypes.map((edgeType) => (
|
|
69
|
+
<li key={edgeType.id} className="eth-dag-legend__item">
|
|
70
|
+
<span
|
|
71
|
+
className={clsx(
|
|
72
|
+
"eth-dag-legend__edge",
|
|
73
|
+
`eth-dag-legend__edge--${edgeType.variant ?? "dependency"}`
|
|
74
|
+
)}
|
|
75
|
+
style={colorVariable(statusColor(edgeType.status))}
|
|
76
|
+
aria-hidden="true"
|
|
77
|
+
/>
|
|
78
|
+
<span className="eth-dag-legend__label">{edgeType.label}</span>
|
|
79
|
+
</li>
|
|
80
|
+
))}
|
|
81
|
+
</ul>
|
|
82
|
+
</div>
|
|
83
|
+
) : null}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import type { DAGNodeProps } from "../types";
|
|
4
|
+
import { labelForStatus, statusColor } from "./utils";
|
|
5
|
+
|
|
6
|
+
const nodeWidth = 156;
|
|
7
|
+
const nodeHeight = 60;
|
|
8
|
+
|
|
9
|
+
function truncateLabel(label: string) {
|
|
10
|
+
return label.length > 20 ? `${label.slice(0, 19)}...` : label;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function DAGNode({ node, selected, onSelect, className, ...props }: DAGNodeProps) {
|
|
14
|
+
const statusLabel = labelForStatus(node.status);
|
|
15
|
+
const x = node.x - nodeWidth / 2;
|
|
16
|
+
const y = node.y - nodeHeight / 2;
|
|
17
|
+
const handleSelect = () => onSelect?.(node);
|
|
18
|
+
const interactive = Boolean(onSelect);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<g
|
|
22
|
+
{...props}
|
|
23
|
+
className={clsx(
|
|
24
|
+
"eth-dag-node",
|
|
25
|
+
`eth-dag-node--${node.status}`,
|
|
26
|
+
interactive && "eth-dag-node--interactive",
|
|
27
|
+
selected && "eth-dag-node--selected",
|
|
28
|
+
className
|
|
29
|
+
)}
|
|
30
|
+
role={interactive ? "button" : "group"}
|
|
31
|
+
tabIndex={interactive ? 0 : undefined}
|
|
32
|
+
aria-label={`${node.label}, ${statusLabel}`}
|
|
33
|
+
aria-pressed={interactive ? Boolean(selected) : undefined}
|
|
34
|
+
onClick={interactive ? handleSelect : undefined}
|
|
35
|
+
onKeyDown={
|
|
36
|
+
interactive
|
|
37
|
+
? (event) => {
|
|
38
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
handleSelect();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
: undefined
|
|
44
|
+
}
|
|
45
|
+
data-eth-component="DAGNode"
|
|
46
|
+
>
|
|
47
|
+
<title>{`${node.label} - ${statusLabel}`}</title>
|
|
48
|
+
<rect
|
|
49
|
+
className="eth-dag-node__selection"
|
|
50
|
+
x={x - 3}
|
|
51
|
+
y={y - 3}
|
|
52
|
+
width={nodeWidth + 6}
|
|
53
|
+
height={nodeHeight + 6}
|
|
54
|
+
rx={0}
|
|
55
|
+
/>
|
|
56
|
+
<rect
|
|
57
|
+
className="eth-dag-node__body"
|
|
58
|
+
x={x}
|
|
59
|
+
y={y}
|
|
60
|
+
width={nodeWidth}
|
|
61
|
+
height={nodeHeight}
|
|
62
|
+
rx={0}
|
|
63
|
+
fill="var(--eth-color-layer-01)"
|
|
64
|
+
/>
|
|
65
|
+
<rect
|
|
66
|
+
className="eth-dag-node__status-strip"
|
|
67
|
+
x={x}
|
|
68
|
+
y={y}
|
|
69
|
+
width={4}
|
|
70
|
+
height={nodeHeight}
|
|
71
|
+
fill={statusColor(node.status)}
|
|
72
|
+
/>
|
|
73
|
+
<circle className="eth-dag-node__port" cx={x} cy={node.y} r={3} />
|
|
74
|
+
<circle className="eth-dag-node__port" cx={x + nodeWidth} cy={node.y} r={3} />
|
|
75
|
+
<circle
|
|
76
|
+
className="eth-dag-node__status-dot"
|
|
77
|
+
cx={x + 18}
|
|
78
|
+
cy={y + 18}
|
|
79
|
+
r={3.5}
|
|
80
|
+
fill={statusColor(node.status)}
|
|
81
|
+
/>
|
|
82
|
+
<text
|
|
83
|
+
x={x + 28}
|
|
84
|
+
y={y + 22}
|
|
85
|
+
textAnchor="start"
|
|
86
|
+
className="eth-dag-node__label"
|
|
87
|
+
>
|
|
88
|
+
{truncateLabel(node.label)}
|
|
89
|
+
</text>
|
|
90
|
+
<text
|
|
91
|
+
x={x + 28}
|
|
92
|
+
y={y + 44}
|
|
93
|
+
textAnchor="start"
|
|
94
|
+
className="eth-dag-node__status"
|
|
95
|
+
>
|
|
96
|
+
{statusLabel}
|
|
97
|
+
</text>
|
|
98
|
+
</g>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const DAG_NODE_WIDTH = nodeWidth;
|
|
103
|
+
export const DAG_NODE_HEIGHT = nodeHeight;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import { InlineNotification, Surface, Tag, type EthAction } from "@echothink-ui/core";
|
|
4
|
+
import type { DecisionOption, DecisionRequiredPanelProps } from "../types";
|
|
5
|
+
import { severityForRisk } from "./utils";
|
|
6
|
+
|
|
7
|
+
interface ResolvedOption extends DecisionOption {
|
|
8
|
+
action?: EthAction;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function optionsFromActions(actions?: EthAction[]): ResolvedOption[] {
|
|
12
|
+
return (
|
|
13
|
+
actions?.map((action) => ({
|
|
14
|
+
id: action.id,
|
|
15
|
+
label: action.label,
|
|
16
|
+
intent: action.intent,
|
|
17
|
+
action
|
|
18
|
+
})) ?? []
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function riskTitle(riskLevel: DecisionRequiredPanelProps["riskLevel"]) {
|
|
23
|
+
if (!riskLevel) return "Decision required";
|
|
24
|
+
return `${riskLevel.charAt(0).toUpperCase()}${riskLevel.slice(1)} risk decision`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function optionIntent(option: ResolvedOption) {
|
|
28
|
+
return option.intent ?? "secondary";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function DecisionRequiredPanel({
|
|
32
|
+
title,
|
|
33
|
+
summary,
|
|
34
|
+
riskLevel,
|
|
35
|
+
evidence = [],
|
|
36
|
+
options,
|
|
37
|
+
onDecide,
|
|
38
|
+
decidedOptionId,
|
|
39
|
+
className,
|
|
40
|
+
description,
|
|
41
|
+
actions,
|
|
42
|
+
items,
|
|
43
|
+
severity,
|
|
44
|
+
status,
|
|
45
|
+
...props
|
|
46
|
+
}: DecisionRequiredPanelProps) {
|
|
47
|
+
const titleId = React.useId();
|
|
48
|
+
const evidenceId = React.useId();
|
|
49
|
+
const optionsId = React.useId();
|
|
50
|
+
const resolvedOptions: ResolvedOption[] =
|
|
51
|
+
options?.map((option) => ({ ...option })) ??
|
|
52
|
+
(actions?.length
|
|
53
|
+
? optionsFromActions(actions)
|
|
54
|
+
: items?.length
|
|
55
|
+
? items.map((item) => ({
|
|
56
|
+
id: item.id,
|
|
57
|
+
label: String(item.label),
|
|
58
|
+
description: item.description ? String(item.description) : undefined,
|
|
59
|
+
intent: item.status === "active" ? "primary" : "secondary"
|
|
60
|
+
}))
|
|
61
|
+
: []);
|
|
62
|
+
const riskSeverity = riskLevel ? severityForRisk(riskLevel) : (severity ?? "warning");
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<Surface
|
|
66
|
+
{...props}
|
|
67
|
+
role="region"
|
|
68
|
+
aria-labelledby={titleId}
|
|
69
|
+
className={clsx(
|
|
70
|
+
"eth-task-decision-panel",
|
|
71
|
+
riskLevel && `eth-task-decision-panel--${riskLevel}`,
|
|
72
|
+
className
|
|
73
|
+
)}
|
|
74
|
+
title={<span id={titleId}>{title}</span>}
|
|
75
|
+
severity={riskSeverity === "neutral" ? severity : riskSeverity}
|
|
76
|
+
status={status ?? "approval-required"}
|
|
77
|
+
data-eth-component="DecisionRequiredPanel"
|
|
78
|
+
>
|
|
79
|
+
<InlineNotification severity={riskSeverity} title={riskTitle(riskLevel)}>
|
|
80
|
+
{description}
|
|
81
|
+
</InlineNotification>
|
|
82
|
+
{summary ? (
|
|
83
|
+
<section className="eth-task-decision-panel__summary" aria-label="Decision context">
|
|
84
|
+
<div className="eth-task-decision-panel__section-label">Decision context</div>
|
|
85
|
+
<div className="eth-task-decision-panel__summary-body">{summary}</div>
|
|
86
|
+
</section>
|
|
87
|
+
) : null}
|
|
88
|
+
|
|
89
|
+
{evidence.length ? (
|
|
90
|
+
<section className="eth-task-decision-panel__evidence" aria-labelledby={evidenceId}>
|
|
91
|
+
<div className="eth-task-decision-panel__section-header">
|
|
92
|
+
<h3 id={evidenceId}>Evidence</h3>
|
|
93
|
+
<span>
|
|
94
|
+
{evidence.length} source{evidence.length === 1 ? "" : "s"}
|
|
95
|
+
</span>
|
|
96
|
+
</div>
|
|
97
|
+
<ul>
|
|
98
|
+
{evidence.map((item) => (
|
|
99
|
+
<li key={item.id}>
|
|
100
|
+
<div className="eth-task-decision-panel__evidence-label">
|
|
101
|
+
{item.href ? (
|
|
102
|
+
<a className="eth-task-decision-panel__evidence-link" href={item.href}>
|
|
103
|
+
{item.label}
|
|
104
|
+
</a>
|
|
105
|
+
) : (
|
|
106
|
+
<strong>{item.label}</strong>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
{item.preview ? (
|
|
110
|
+
<div className="eth-task-decision-panel__evidence-preview">{item.preview}</div>
|
|
111
|
+
) : null}
|
|
112
|
+
</li>
|
|
113
|
+
))}
|
|
114
|
+
</ul>
|
|
115
|
+
</section>
|
|
116
|
+
) : null}
|
|
117
|
+
|
|
118
|
+
{resolvedOptions.length ? (
|
|
119
|
+
<section
|
|
120
|
+
className="eth-task-decision-panel__options"
|
|
121
|
+
role="group"
|
|
122
|
+
aria-labelledby={optionsId}
|
|
123
|
+
>
|
|
124
|
+
<div className="eth-task-decision-panel__section-header">
|
|
125
|
+
<h3 id={optionsId}>Decision options</h3>
|
|
126
|
+
<span>{resolvedOptions.length} available</span>
|
|
127
|
+
</div>
|
|
128
|
+
{resolvedOptions.map((option) => (
|
|
129
|
+
<button
|
|
130
|
+
key={option.id}
|
|
131
|
+
type="button"
|
|
132
|
+
className={clsx(
|
|
133
|
+
"eth-task-decision-panel__option",
|
|
134
|
+
`eth-task-decision-panel__option--${optionIntent(option)}`
|
|
135
|
+
)}
|
|
136
|
+
disabled={option.action?.disabled}
|
|
137
|
+
aria-pressed={decidedOptionId === option.id}
|
|
138
|
+
onClick={() => {
|
|
139
|
+
option.action?.onSelect?.();
|
|
140
|
+
onDecide?.(option.id);
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<span className="eth-task-decision-panel__option-main">
|
|
144
|
+
<span className="eth-task-decision-panel__option-label">{option.label}</span>
|
|
145
|
+
{option.description ? (
|
|
146
|
+
<span className="eth-task-decision-panel__option-description">
|
|
147
|
+
{option.description}
|
|
148
|
+
</span>
|
|
149
|
+
) : null}
|
|
150
|
+
</span>
|
|
151
|
+
<span className="eth-task-decision-panel__option-state">
|
|
152
|
+
{decidedOptionId === option.id ? "Selected" : "Choose"}
|
|
153
|
+
</span>
|
|
154
|
+
</button>
|
|
155
|
+
))}
|
|
156
|
+
</section>
|
|
157
|
+
) : null}
|
|
158
|
+
|
|
159
|
+
{decidedOptionId ? (
|
|
160
|
+
<div className="eth-task-decision-panel__decision">
|
|
161
|
+
<Tag>Decided: {decidedOptionId}</Tag>
|
|
162
|
+
</div>
|
|
163
|
+
) : null}
|
|
164
|
+
</Surface>
|
|
165
|
+
);
|
|
166
|
+
}
|