@echothink-ui/todo 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/KanbanBoard.d.ts +8 -0
- package/dist/components/KanbanColumn.d.ts +8 -0
- package/dist/components/TaskCard.d.ts +6 -0
- package/dist/components/TaskDependencyList.d.ts +6 -0
- package/dist/components/TaskTable.d.ts +9 -0
- package/dist/components/TaskTimeline.d.ts +6 -0
- package/dist/components/TodoItem.d.ts +7 -0
- package/dist/components/TodoList.d.ts +8 -0
- package/dist/index.cjs +590 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +500 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +545 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +49 -0
- package/dist/utils.d.ts +3 -0
- package/package.json +43 -0
- package/src/components/KanbanBoard.tsx +161 -0
- package/src/components/KanbanColumn.tsx +105 -0
- package/src/components/TaskCard.tsx +74 -0
- package/src/components/TaskDependencyList.tsx +56 -0
- package/src/components/TaskTable.tsx +69 -0
- package/src/components/TaskTimeline.tsx +40 -0
- package/src/components/TodoItem.test.tsx +46 -0
- package/src/components/TodoItem.tsx +62 -0
- package/src/components/TodoList.tsx +65 -0
- package/src/index.test.tsx +46 -0
- package/src/index.tsx +26 -0
- package/src/styles.css +591 -0
- package/src/types.ts +56 -0
- package/src/utils.ts +26 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type * as React from "react";
|
|
2
|
+
import type { EthAction, EthOperationalStatus } from "@echothink-ui/core";
|
|
3
|
+
export interface TodoTaskItem {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
done: boolean;
|
|
7
|
+
dueAt?: string;
|
|
8
|
+
assignee?: string;
|
|
9
|
+
priority?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface KanbanCard {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
assignee?: string;
|
|
15
|
+
priority?: string;
|
|
16
|
+
status?: string;
|
|
17
|
+
dueAt?: string;
|
|
18
|
+
labels?: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface KanbanColumnModel {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
status?: string;
|
|
24
|
+
wipLimit?: number;
|
|
25
|
+
items: KanbanCard[];
|
|
26
|
+
}
|
|
27
|
+
export interface TaskTableTask extends Record<string, unknown> {
|
|
28
|
+
id: string;
|
|
29
|
+
title: string;
|
|
30
|
+
status?: EthOperationalStatus;
|
|
31
|
+
assignee?: string;
|
|
32
|
+
priority?: string;
|
|
33
|
+
dueAt?: string;
|
|
34
|
+
labels?: string[];
|
|
35
|
+
}
|
|
36
|
+
export interface TaskDependency {
|
|
37
|
+
id: string;
|
|
38
|
+
title: string;
|
|
39
|
+
status: string;
|
|
40
|
+
relation: "blocks" | "blocked-by";
|
|
41
|
+
}
|
|
42
|
+
export interface TaskTimelineEvent {
|
|
43
|
+
id: string;
|
|
44
|
+
timestamp: string;
|
|
45
|
+
actor?: string;
|
|
46
|
+
kind: string;
|
|
47
|
+
summary: React.ReactNode;
|
|
48
|
+
}
|
|
49
|
+
export type TaskRowActions = (task: TaskTableTask) => EthAction[];
|
package/dist/utils.d.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@echothink-ui/todo",
|
|
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
|
+
"@echothink-ui/core": "0.2.0",
|
|
31
|
+
"@echothink-ui/icons": "0.2.0",
|
|
32
|
+
"@echothink-ui/data": "0.2.0"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"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",
|
|
39
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
40
|
+
"test": "vitest run --config ../../vitest.config.ts --passWithNoTests",
|
|
41
|
+
"lint": "eslint src"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { KanbanCard, KanbanColumnModel } from "../types";
|
|
3
|
+
import { KanbanColumn } from "./KanbanColumn";
|
|
4
|
+
import { TaskCard } from "./TaskCard";
|
|
5
|
+
|
|
6
|
+
export interface KanbanBoardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
columns?: KanbanColumnModel[];
|
|
8
|
+
onCardMove?: (cardId: string, fromColumn: string, toColumn: string, index: number) => void;
|
|
9
|
+
onAddCard?: (columnId: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface LiftedCard {
|
|
13
|
+
cardId: string;
|
|
14
|
+
fromColumn: string;
|
|
15
|
+
toColumn: string;
|
|
16
|
+
index: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function KanbanBoard({
|
|
20
|
+
columns = [],
|
|
21
|
+
onCardMove,
|
|
22
|
+
onAddCard,
|
|
23
|
+
className,
|
|
24
|
+
...props
|
|
25
|
+
}: KanbanBoardProps) {
|
|
26
|
+
const [announcement, setAnnouncement] = React.useState("");
|
|
27
|
+
const [lifted, setLifted] = React.useState<LiftedCard | null>(null);
|
|
28
|
+
|
|
29
|
+
const findCard = (cardId: string) => {
|
|
30
|
+
for (const column of columns) {
|
|
31
|
+
const index = column.items.findIndex((item) => item.id === cardId);
|
|
32
|
+
if (index >= 0) return { column, index, card: column.items[index] };
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const announce = (message: string) => setAnnouncement(message);
|
|
38
|
+
|
|
39
|
+
const moveLifted = (direction: "left" | "right" | "up" | "down") => {
|
|
40
|
+
if (!lifted) return;
|
|
41
|
+
const currentColumnIndex = columns.findIndex((column) => column.id === lifted.toColumn);
|
|
42
|
+
if (currentColumnIndex < 0) return;
|
|
43
|
+
if (direction === "left" || direction === "right") {
|
|
44
|
+
const offset = direction === "left" ? -1 : 1;
|
|
45
|
+
const nextColumn = columns[currentColumnIndex + offset];
|
|
46
|
+
if (!nextColumn) return;
|
|
47
|
+
const nextIndex = Math.min(lifted.index, Math.max(0, nextColumn.items.length));
|
|
48
|
+
setLifted({ ...lifted, toColumn: nextColumn.id, index: nextIndex });
|
|
49
|
+
announce(`Moved ${lifted.cardId} to ${nextColumn.title}, position ${nextIndex + 1}`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const currentColumn = columns[currentColumnIndex];
|
|
53
|
+
const maxIndex = Math.max(0, currentColumn.items.length - 1);
|
|
54
|
+
const nextIndex =
|
|
55
|
+
direction === "up" ? Math.max(0, lifted.index - 1) : Math.min(maxIndex, lifted.index + 1);
|
|
56
|
+
setLifted({ ...lifted, index: nextIndex });
|
|
57
|
+
announce(`Moved ${lifted.cardId} to position ${nextIndex + 1}`);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const handleCardKeyDown = (
|
|
61
|
+
event: React.KeyboardEvent<HTMLDivElement>,
|
|
62
|
+
card: KanbanCard,
|
|
63
|
+
column: KanbanColumnModel,
|
|
64
|
+
index: number
|
|
65
|
+
) => {
|
|
66
|
+
if (event.key === " ") {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
setLifted({ cardId: card.id, fromColumn: column.id, toColumn: column.id, index });
|
|
69
|
+
announce(`Lifted ${card.title}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!lifted) return;
|
|
73
|
+
if (event.key === "ArrowLeft") {
|
|
74
|
+
event.preventDefault();
|
|
75
|
+
moveLifted("left");
|
|
76
|
+
}
|
|
77
|
+
if (event.key === "ArrowRight") {
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
moveLifted("right");
|
|
80
|
+
}
|
|
81
|
+
if (event.key === "ArrowUp") {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
moveLifted("up");
|
|
84
|
+
}
|
|
85
|
+
if (event.key === "ArrowDown") {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
moveLifted("down");
|
|
88
|
+
}
|
|
89
|
+
if (event.key === "Enter") {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
onCardMove?.(lifted.cardId, lifted.fromColumn, lifted.toColumn, lifted.index);
|
|
92
|
+
announce(`Dropped ${lifted.cardId}`);
|
|
93
|
+
setLifted(null);
|
|
94
|
+
}
|
|
95
|
+
if (event.key === "Escape") {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
announce(`Cancelled move for ${lifted.cardId}`);
|
|
98
|
+
setLifted(null);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleDrop = (event: React.DragEvent, toColumn: KanbanColumnModel, index: number) => {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
const cardId = event.dataTransfer.getData("text/plain");
|
|
105
|
+
const source = findCard(cardId);
|
|
106
|
+
if (!source) return;
|
|
107
|
+
onCardMove?.(cardId, source.column.id, toColumn.id, index);
|
|
108
|
+
announce(`Moved ${source.card.title} to ${toColumn.title}`);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div
|
|
113
|
+
{...props}
|
|
114
|
+
className={`eth-kanban-board ${className ?? ""}`}
|
|
115
|
+
data-eth-component="KanbanBoard"
|
|
116
|
+
>
|
|
117
|
+
<div className="eth-kanban-board__columns" role="list" aria-label="Kanban columns">
|
|
118
|
+
{columns.map((column) => (
|
|
119
|
+
<KanbanColumn
|
|
120
|
+
key={column.id}
|
|
121
|
+
column={column}
|
|
122
|
+
role="listitem"
|
|
123
|
+
onAddCard={onAddCard}
|
|
124
|
+
onDragOver={(event) => event.preventDefault()}
|
|
125
|
+
onDrop={(event) => handleDrop(event, column, column.items.length)}
|
|
126
|
+
>
|
|
127
|
+
{column.items.map((card, index) => (
|
|
128
|
+
<div
|
|
129
|
+
key={card.id}
|
|
130
|
+
className={`eth-kanban-card-shell ${
|
|
131
|
+
lifted?.cardId === card.id ? "eth-kanban-card-shell--lifted" : ""
|
|
132
|
+
}`}
|
|
133
|
+
draggable
|
|
134
|
+
tabIndex={0}
|
|
135
|
+
role="listitem"
|
|
136
|
+
aria-label={`${card.title}, ${column.title}, position ${index + 1} of ${
|
|
137
|
+
column.items.length
|
|
138
|
+
}`}
|
|
139
|
+
aria-grabbed={lifted?.cardId === card.id}
|
|
140
|
+
aria-roledescription="Draggable task card"
|
|
141
|
+
aria-keyshortcuts="Space ArrowLeft ArrowRight ArrowUp ArrowDown Enter Escape"
|
|
142
|
+
onDragStart={(event) => {
|
|
143
|
+
event.dataTransfer.setData("text/plain", card.id);
|
|
144
|
+
event.dataTransfer.effectAllowed = "move";
|
|
145
|
+
}}
|
|
146
|
+
onDragOver={(event) => event.preventDefault()}
|
|
147
|
+
onDrop={(event) => handleDrop(event, column, index)}
|
|
148
|
+
onKeyDown={(event) => handleCardKeyDown(event, card, column, index)}
|
|
149
|
+
>
|
|
150
|
+
<TaskCard card={card} />
|
|
151
|
+
</div>
|
|
152
|
+
))}
|
|
153
|
+
</KanbanColumn>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
<div className="eth-kanban-board__live" aria-live="polite" aria-atomic="true">
|
|
157
|
+
{announcement}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Badge, IconButton } from "@echothink-ui/core";
|
|
3
|
+
import type { KanbanColumnModel } from "../types";
|
|
4
|
+
|
|
5
|
+
export interface KanbanColumnProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
column: KanbanColumnModel;
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
onAddCard?: (columnId: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function KanbanColumn({
|
|
12
|
+
column,
|
|
13
|
+
children,
|
|
14
|
+
onAddCard,
|
|
15
|
+
className,
|
|
16
|
+
"aria-label": ariaLabel,
|
|
17
|
+
...props
|
|
18
|
+
}: KanbanColumnProps) {
|
|
19
|
+
const itemCount = column.items.length;
|
|
20
|
+
const taskLabel = `${itemCount} ${itemCount === 1 ? "task" : "tasks"}`;
|
|
21
|
+
const wipLimit = column.wipLimit;
|
|
22
|
+
const hasWipLimit = typeof wipLimit === "number";
|
|
23
|
+
const overLimit = hasWipLimit ? itemCount > wipLimit : false;
|
|
24
|
+
const atLimit = hasWipLimit ? itemCount === wipLimit : false;
|
|
25
|
+
const hasRenderedChildren = React.Children.count(children) > 0;
|
|
26
|
+
const titleId = React.useId();
|
|
27
|
+
const { "aria-labelledby": ariaLabelledBy, ...sectionProps } = props;
|
|
28
|
+
|
|
29
|
+
let wipPercent = 0;
|
|
30
|
+
let wipStatusLabel = "";
|
|
31
|
+
let wipSeverity: "danger" | "warning" | "neutral" = "neutral";
|
|
32
|
+
|
|
33
|
+
if (hasWipLimit) {
|
|
34
|
+
wipPercent =
|
|
35
|
+
wipLimit > 0 ? Math.min(100, (itemCount / wipLimit) * 100) : itemCount > 0 ? 100 : 0;
|
|
36
|
+
const remaining = wipLimit - itemCount;
|
|
37
|
+
wipStatusLabel = overLimit
|
|
38
|
+
? `${Math.abs(remaining)} over limit`
|
|
39
|
+
: remaining === 0
|
|
40
|
+
? "At limit"
|
|
41
|
+
: `${remaining} ${remaining === 1 ? "slot" : "slots"} open`;
|
|
42
|
+
if (overLimit) wipSeverity = "danger";
|
|
43
|
+
else if (atLimit) wipSeverity = "warning";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<section
|
|
48
|
+
{...sectionProps}
|
|
49
|
+
aria-label={ariaLabel}
|
|
50
|
+
aria-labelledby={ariaLabel ? undefined : (ariaLabelledBy ?? titleId)}
|
|
51
|
+
className={`eth-kanban-column ${overLimit ? "eth-kanban-column--over-limit" : ""} ${
|
|
52
|
+
className ?? ""
|
|
53
|
+
}`}
|
|
54
|
+
data-eth-component="KanbanColumn"
|
|
55
|
+
>
|
|
56
|
+
<header className="eth-kanban-column__header">
|
|
57
|
+
<div className="eth-kanban-column__header-main">
|
|
58
|
+
<div className="eth-kanban-column__heading">
|
|
59
|
+
<h3 id={titleId}>{column.title}</h3>
|
|
60
|
+
<span className="eth-kanban-column__count">{taskLabel}</span>
|
|
61
|
+
</div>
|
|
62
|
+
{onAddCard ? (
|
|
63
|
+
<div className="eth-kanban-column__actions">
|
|
64
|
+
<IconButton
|
|
65
|
+
className="eth-kanban-column__add"
|
|
66
|
+
intent="ghost"
|
|
67
|
+
density="compact"
|
|
68
|
+
label={`Add task to ${column.title}`}
|
|
69
|
+
icon={
|
|
70
|
+
<span className="eth-kanban-column__add-glyph" aria-hidden="true">
|
|
71
|
+
+
|
|
72
|
+
</span>
|
|
73
|
+
}
|
|
74
|
+
onClick={() => onAddCard(column.id)}
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
) : null}
|
|
78
|
+
</div>
|
|
79
|
+
{hasWipLimit ? (
|
|
80
|
+
<div className="eth-kanban-column__metrics">
|
|
81
|
+
<div
|
|
82
|
+
className="eth-kanban-column__limit"
|
|
83
|
+
aria-label={`Work in progress ${itemCount} of ${wipLimit}`}
|
|
84
|
+
>
|
|
85
|
+
<div className="eth-kanban-column__limit-row">
|
|
86
|
+
<Badge severity={wipSeverity}>WIP {itemCount}/{wipLimit}</Badge>
|
|
87
|
+
<span className="eth-kanban-column__limit-note">{wipStatusLabel}</span>
|
|
88
|
+
</div>
|
|
89
|
+
<span className="eth-kanban-column__meter" aria-hidden="true">
|
|
90
|
+
<span style={{ inlineSize: `${wipPercent}%` }} />
|
|
91
|
+
</span>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
) : null}
|
|
95
|
+
</header>
|
|
96
|
+
<div className="eth-kanban-column__body" role="list" aria-label={`${column.title} tasks`}>
|
|
97
|
+
{hasRenderedChildren ? (
|
|
98
|
+
children
|
|
99
|
+
) : itemCount === 0 ? (
|
|
100
|
+
<div className="eth-kanban-column__empty">No tasks in this column</div>
|
|
101
|
+
) : null}
|
|
102
|
+
</div>
|
|
103
|
+
</section>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Badge, Surface, StatusDot, Tag, type EthOperationalStatus } from "@echothink-ui/core";
|
|
3
|
+
import type { KanbanCard } from "../types";
|
|
4
|
+
import { formatDateTime, priorityLabel, prioritySeverity } from "../utils";
|
|
5
|
+
|
|
6
|
+
export interface TaskCardProps extends React.HTMLAttributes<HTMLElement> {
|
|
7
|
+
card: KanbanCard;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const operationalStatuses = new Set<string>([
|
|
11
|
+
"queued",
|
|
12
|
+
"running",
|
|
13
|
+
"paused",
|
|
14
|
+
"blocked",
|
|
15
|
+
"failed",
|
|
16
|
+
"succeeded",
|
|
17
|
+
"warning",
|
|
18
|
+
"stale",
|
|
19
|
+
"synced",
|
|
20
|
+
"pending-approval",
|
|
21
|
+
"approval-required",
|
|
22
|
+
"in-progress",
|
|
23
|
+
"not-started",
|
|
24
|
+
"completed",
|
|
25
|
+
"active",
|
|
26
|
+
"inactive"
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function isOperationalStatus(status: string | undefined): status is EthOperationalStatus {
|
|
30
|
+
return Boolean(status && operationalStatuses.has(status));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function statusLabel(status: string) {
|
|
34
|
+
return status.replace(/-/g, " ");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function TaskCard({ card, className, ...props }: TaskCardProps) {
|
|
38
|
+
return (
|
|
39
|
+
<Surface
|
|
40
|
+
{...props}
|
|
41
|
+
className={`eth-todo-task-card ${className ?? ""}`}
|
|
42
|
+
data-priority={card.priority}
|
|
43
|
+
data-status={card.status}
|
|
44
|
+
data-eth-component="TaskCard"
|
|
45
|
+
>
|
|
46
|
+
<div className="eth-todo-task-card__header">
|
|
47
|
+
<strong>{card.title}</strong>
|
|
48
|
+
{card.priority ? (
|
|
49
|
+
<Badge severity={prioritySeverity(card.priority)}>{priorityLabel(card.priority)}</Badge>
|
|
50
|
+
) : null}
|
|
51
|
+
</div>
|
|
52
|
+
<div className="eth-todo-task-card__meta">
|
|
53
|
+
{card.assignee ? <Tag>{card.assignee}</Tag> : null}
|
|
54
|
+
{isOperationalStatus(card.status) ? (
|
|
55
|
+
<StatusDot status={card.status} label={statusLabel(card.status)} />
|
|
56
|
+
) : card.status ? (
|
|
57
|
+
<Tag>{card.status}</Tag>
|
|
58
|
+
) : null}
|
|
59
|
+
{card.dueAt ? (
|
|
60
|
+
<time className="eth-todo-task-card__due" dateTime={card.dueAt}>
|
|
61
|
+
{formatDateTime(card.dueAt)}
|
|
62
|
+
</time>
|
|
63
|
+
) : null}
|
|
64
|
+
</div>
|
|
65
|
+
{card.labels?.length ? (
|
|
66
|
+
<div className="eth-todo-task-card__labels">
|
|
67
|
+
{card.labels.map((label) => (
|
|
68
|
+
<Tag key={label}>{label}</Tag>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
) : null}
|
|
72
|
+
</Surface>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Badge, Panel } from "@echothink-ui/core";
|
|
3
|
+
import type { TaskDependency } from "../types";
|
|
4
|
+
|
|
5
|
+
export interface TaskDependencyListProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
dependencies?: TaskDependency[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function TaskDependencyList({
|
|
10
|
+
dependencies = [],
|
|
11
|
+
className,
|
|
12
|
+
...props
|
|
13
|
+
}: TaskDependencyListProps) {
|
|
14
|
+
const groups = {
|
|
15
|
+
"blocked-by": dependencies.filter((dependency) => dependency.relation === "blocked-by"),
|
|
16
|
+
blocks: dependencies.filter((dependency) => dependency.relation === "blocks")
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Panel
|
|
21
|
+
{...props}
|
|
22
|
+
className={`eth-todo-dependencies ${className ?? ""}`}
|
|
23
|
+
title="Dependencies"
|
|
24
|
+
data-eth-component="TaskDependencyList"
|
|
25
|
+
>
|
|
26
|
+
<DependencyGroup title="Blocked by" dependencies={groups["blocked-by"]} />
|
|
27
|
+
<DependencyGroup title="Blocks" dependencies={groups.blocks} />
|
|
28
|
+
</Panel>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function DependencyGroup({
|
|
33
|
+
title,
|
|
34
|
+
dependencies
|
|
35
|
+
}: {
|
|
36
|
+
title: string;
|
|
37
|
+
dependencies: TaskDependency[];
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<section className="eth-todo-dependencies__group">
|
|
41
|
+
<h3>{title}</h3>
|
|
42
|
+
{dependencies.length ? (
|
|
43
|
+
<ul>
|
|
44
|
+
{dependencies.map((dependency) => (
|
|
45
|
+
<li key={dependency.id}>
|
|
46
|
+
<span>{dependency.title}</span>
|
|
47
|
+
<Badge>{dependency.status}</Badge>
|
|
48
|
+
</li>
|
|
49
|
+
))}
|
|
50
|
+
</ul>
|
|
51
|
+
) : (
|
|
52
|
+
<p>None</p>
|
|
53
|
+
)}
|
|
54
|
+
</section>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Badge, StatusDot, Tag } from "@echothink-ui/core";
|
|
3
|
+
import { DataTable, type DataColumn } from "@echothink-ui/data";
|
|
4
|
+
import type { TaskRowActions, TaskTableTask } from "../types";
|
|
5
|
+
import { formatDateTime } from "../utils";
|
|
6
|
+
|
|
7
|
+
export interface TaskTableProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
tasks?: TaskTableTask[];
|
|
9
|
+
density?: "compact" | "default" | "comfortable";
|
|
10
|
+
selectable?: boolean;
|
|
11
|
+
rowActions?: TaskRowActions;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const columns: DataColumn<TaskTableTask>[] = [
|
|
15
|
+
{
|
|
16
|
+
key: "title",
|
|
17
|
+
header: "Task",
|
|
18
|
+
render: (task) => (
|
|
19
|
+
<div className="eth-todo-task-table__title">
|
|
20
|
+
<strong>{task.title}</strong>
|
|
21
|
+
{task.labels?.map((label) => (
|
|
22
|
+
<Tag key={label}>{label}</Tag>
|
|
23
|
+
))}
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
key: "status",
|
|
29
|
+
header: "Status",
|
|
30
|
+
render: (task) => (task.status ? <StatusDot status={task.status} label={task.status} /> : null)
|
|
31
|
+
},
|
|
32
|
+
{ key: "assignee", header: "Assignee" },
|
|
33
|
+
{
|
|
34
|
+
key: "priority",
|
|
35
|
+
header: "Priority",
|
|
36
|
+
render: (task) => (task.priority ? <Badge>{task.priority}</Badge> : null)
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: "dueAt",
|
|
40
|
+
header: "Due",
|
|
41
|
+
render: (task) => (task.dueAt ? <time dateTime={task.dueAt}>{formatDateTime(task.dueAt)}</time> : null)
|
|
42
|
+
}
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
export function TaskTable({
|
|
46
|
+
tasks = [],
|
|
47
|
+
density = "default",
|
|
48
|
+
selectable,
|
|
49
|
+
rowActions,
|
|
50
|
+
className,
|
|
51
|
+
...props
|
|
52
|
+
}: TaskTableProps) {
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
{...props}
|
|
56
|
+
className={`eth-todo-task-table ${className ?? ""}`}
|
|
57
|
+
data-eth-component="TaskTable"
|
|
58
|
+
>
|
|
59
|
+
<DataTable
|
|
60
|
+
rows={tasks}
|
|
61
|
+
columns={columns}
|
|
62
|
+
rowKey="id"
|
|
63
|
+
density={density}
|
|
64
|
+
selectable={selectable}
|
|
65
|
+
rowActions={rowActions}
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Panel, Tag } from "@echothink-ui/core";
|
|
3
|
+
import type { TaskTimelineEvent } from "../types";
|
|
4
|
+
import { formatDateTime } from "../utils";
|
|
5
|
+
|
|
6
|
+
export interface TaskTimelineProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
events?: TaskTimelineEvent[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function TaskTimeline({ events = [], className, ...props }: TaskTimelineProps) {
|
|
11
|
+
const sortedEvents = React.useMemo(
|
|
12
|
+
() =>
|
|
13
|
+
[...events].sort(
|
|
14
|
+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
15
|
+
),
|
|
16
|
+
[events]
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Panel
|
|
21
|
+
{...props}
|
|
22
|
+
className={`eth-todo-timeline ${className ?? ""}`}
|
|
23
|
+
title="Task timeline"
|
|
24
|
+
data-eth-component="TaskTimeline"
|
|
25
|
+
>
|
|
26
|
+
<ol className="eth-todo-timeline__events">
|
|
27
|
+
{sortedEvents.map((event) => (
|
|
28
|
+
<li key={event.id} className="eth-todo-timeline__event">
|
|
29
|
+
<time dateTime={event.timestamp}>{formatDateTime(event.timestamp)}</time>
|
|
30
|
+
<div>
|
|
31
|
+
<Tag>{event.kind}</Tag>
|
|
32
|
+
{event.actor ? <span>{event.actor}</span> : null}
|
|
33
|
+
<p>{event.summary}</p>
|
|
34
|
+
</div>
|
|
35
|
+
</li>
|
|
36
|
+
))}
|
|
37
|
+
</ol>
|
|
38
|
+
</Panel>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { TodoItem } from "./TodoItem";
|
|
4
|
+
|
|
5
|
+
describe("@echothink-ui/todo TodoItem", () => {
|
|
6
|
+
it("renders a compact actionable task row with structured metadata", () => {
|
|
7
|
+
const onToggle = vi.fn();
|
|
8
|
+
|
|
9
|
+
const { container } = render(
|
|
10
|
+
<TodoItem
|
|
11
|
+
item={{
|
|
12
|
+
id: "brief",
|
|
13
|
+
label: "Approve campaign brief",
|
|
14
|
+
done: false,
|
|
15
|
+
assignee: "MK",
|
|
16
|
+
priority: "high",
|
|
17
|
+
dueAt: "2026-05-30T16:00:00Z"
|
|
18
|
+
}}
|
|
19
|
+
onToggle={onToggle}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const row = container.querySelector('[data-eth-component="TodoItem"]');
|
|
24
|
+
const due = container.querySelector(".eth-todo-item__due time");
|
|
25
|
+
|
|
26
|
+
expect(row?.getAttribute("data-priority")).toBe("high");
|
|
27
|
+
expect(screen.getByRole("checkbox", { name: "Approve campaign brief" })).toBeTruthy();
|
|
28
|
+
expect(screen.getByText("MK")).toBeTruthy();
|
|
29
|
+
expect(screen.getByText("high").closest(".eth-badge")).toBeTruthy();
|
|
30
|
+
expect(due?.getAttribute("datetime")).toBe("2026-05-30T16:00:00Z");
|
|
31
|
+
|
|
32
|
+
fireEvent.click(screen.getByRole("checkbox", { name: "Approve campaign brief" }));
|
|
33
|
+
expect(onToggle).toHaveBeenCalledWith("brief");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not emit an invalid datetime attribute for relative due labels", () => {
|
|
37
|
+
const { container } = render(
|
|
38
|
+
<TodoItem item={{ id: "relative", label: "Approve brief", done: false, dueAt: "today" }} />
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const due = container.querySelector(".eth-todo-item__due time");
|
|
42
|
+
|
|
43
|
+
expect(due?.textContent).toBe("today");
|
|
44
|
+
expect(due?.hasAttribute("datetime")).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|