@dxos/react-ui-task 0.12.0-next-2800d0376abcc62d5544afdd744b16b2b960434e → 0.13.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/dist/lib/index.mjs +2024 -572
- package/dist/lib/index.mjs.map +1 -1
- package/dist/lib/translations.mjs +31 -1
- package/dist/lib/translations.mjs.map +1 -1
- package/dist/types/src/components/TaskHistory/TaskHistory.d.ts +13 -0
- package/dist/types/src/components/TaskHistory/TaskHistory.d.ts.map +1 -0
- package/dist/types/src/components/TaskHistory/TaskHistory.stories.d.ts +68 -0
- package/dist/types/src/components/TaskHistory/TaskHistory.stories.d.ts.map +1 -0
- package/dist/types/src/components/TaskHistory/index.d.ts +2 -0
- package/dist/types/src/components/TaskHistory/index.d.ts.map +1 -0
- package/dist/types/src/components/TaskList/TaskDescription.d.ts +20 -0
- package/dist/types/src/components/TaskList/TaskDescription.d.ts.map +1 -0
- package/dist/types/src/components/TaskList/TaskList.d.ts +122 -57
- package/dist/types/src/components/TaskList/TaskList.d.ts.map +1 -1
- package/dist/types/src/components/TaskList/TaskList.stories.d.ts +163 -5
- package/dist/types/src/components/TaskList/TaskList.stories.d.ts.map +1 -1
- package/dist/types/src/components/TaskList/TaskListContext.d.ts +53 -0
- package/dist/types/src/components/TaskList/TaskListContext.d.ts.map +1 -0
- package/dist/types/src/components/TaskList/TaskListEditor.d.ts +42 -0
- package/dist/types/src/components/TaskList/TaskListEditor.d.ts.map +1 -0
- package/dist/types/src/components/TaskList/TaskRowCells.d.ts +86 -0
- package/dist/types/src/components/TaskList/TaskRowCells.d.ts.map +1 -0
- package/dist/types/src/components/TaskList/TaskTreeNode.d.ts +65 -0
- package/dist/types/src/components/TaskList/TaskTreeNode.d.ts.map +1 -0
- package/dist/types/src/components/TaskList/anchor-hover.d.ts +21 -0
- package/dist/types/src/components/TaskList/anchor-hover.d.ts.map +1 -0
- package/dist/types/src/components/TaskList/anchor-hover.test.d.ts +2 -0
- package/dist/types/src/components/TaskList/anchor-hover.test.d.ts.map +1 -0
- package/dist/types/src/components/TaskList/hierarchy.d.ts +13 -26
- package/dist/types/src/components/TaskList/hierarchy.d.ts.map +1 -1
- package/dist/types/src/components/TaskList/index.d.ts +4 -2
- package/dist/types/src/components/TaskList/index.d.ts.map +1 -1
- package/dist/types/src/components/TaskList/tracks.d.ts +16 -0
- package/dist/types/src/components/TaskList/tracks.d.ts.map +1 -0
- package/dist/types/src/components/TaskList/tree-model.d.ts +107 -0
- package/dist/types/src/components/TaskList/tree-model.d.ts.map +1 -0
- package/dist/types/src/components/TaskList/tree-model.test.d.ts +2 -0
- package/dist/types/src/components/TaskList/tree-model.test.d.ts.map +1 -0
- package/dist/types/src/components/TaskList/usePreviewAnchor.d.ts +21 -0
- package/dist/types/src/components/TaskList/usePreviewAnchor.d.ts.map +1 -0
- package/dist/types/src/components/TaskProperties/TaskProperties.d.ts +23 -0
- package/dist/types/src/components/TaskProperties/TaskProperties.d.ts.map +1 -0
- package/dist/types/src/components/TaskProperties/TaskProperties.stories.d.ts +76 -0
- package/dist/types/src/components/TaskProperties/TaskProperties.stories.d.ts.map +1 -0
- package/dist/types/src/components/TaskProperties/index.d.ts +2 -0
- package/dist/types/src/components/TaskProperties/index.d.ts.map +1 -0
- package/dist/types/src/components/TaskQuestion/TaskQuestion.d.ts +22 -0
- package/dist/types/src/components/TaskQuestion/TaskQuestion.d.ts.map +1 -0
- package/dist/types/src/components/TaskQuestion/index.d.ts +2 -0
- package/dist/types/src/components/TaskQuestion/index.d.ts.map +1 -0
- package/dist/types/src/components/task-grid.d.ts +18 -0
- package/dist/types/src/components/task-grid.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +2 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -0
- package/dist/types/src/hooks/useAssigneeDisplay.d.ts +8 -0
- package/dist/types/src/hooks/useAssigneeDisplay.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +6 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/translations.d.ts +31 -1
- package/dist/types/src/translations.d.ts.map +1 -1
- package/dist/types/src/util/assignee.d.ts +34 -0
- package/dist/types/src/util/assignee.d.ts.map +1 -0
- package/dist/types/src/util/assignee.test.d.ts +2 -0
- package/dist/types/src/util/assignee.test.d.ts.map +1 -0
- package/dist/types/src/util/format-date.d.ts +18 -0
- package/dist/types/src/util/format-date.d.ts.map +1 -0
- package/dist/types/src/util/format-date.test.d.ts +2 -0
- package/dist/types/src/util/format-date.test.d.ts.map +1 -0
- package/dist/types/src/util/index.d.ts +3 -0
- package/dist/types/src/util/index.d.ts.map +1 -0
- package/dist/types/src/util/status-icons.d.ts +28 -0
- package/dist/types/src/util/status-icons.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +27 -20
- package/src/components/TaskHistory/TaskHistory.stories.tsx +103 -0
- package/src/components/TaskHistory/TaskHistory.tsx +169 -0
- package/src/components/TaskHistory/index.ts +5 -0
- package/src/components/TaskList/TaskDescription.tsx +39 -0
- package/src/components/TaskList/TaskList.stories.tsx +1882 -181
- package/src/components/TaskList/TaskList.tsx +670 -789
- package/src/components/TaskList/TaskListContext.ts +68 -0
- package/src/components/TaskList/TaskListEditor.tsx +510 -0
- package/src/components/TaskList/TaskRowCells.tsx +362 -0
- package/src/components/TaskList/TaskTreeNode.tsx +387 -0
- package/src/components/TaskList/anchor-hover.test.ts +84 -0
- package/src/components/TaskList/anchor-hover.ts +129 -0
- package/src/components/TaskList/hierarchy.test.ts +92 -51
- package/src/components/TaskList/hierarchy.ts +45 -63
- package/src/components/TaskList/index.ts +5 -2
- package/src/components/TaskList/tracks.ts +19 -0
- package/src/components/TaskList/tree-model.test.ts +233 -0
- package/src/components/TaskList/tree-model.ts +238 -0
- package/src/components/TaskList/usePreviewAnchor.ts +85 -0
- package/src/components/TaskProperties/TaskProperties.stories.tsx +121 -0
- package/src/components/TaskProperties/TaskProperties.tsx +281 -0
- package/src/components/TaskProperties/index.ts +5 -0
- package/src/components/TaskQuestion/TaskQuestion.tsx +223 -0
- package/src/components/TaskQuestion/index.ts +5 -0
- package/src/components/task-grid.ts +23 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useAssigneeDisplay.ts +26 -0
- package/src/index.ts +6 -1
- package/src/translations.ts +33 -3
- package/src/util/assignee.test.ts +73 -0
- package/src/util/assignee.ts +69 -0
- package/src/util/format-date.test.ts +36 -0
- package/src/util/format-date.ts +43 -0
- package/src/util/index.ts +6 -0
- package/src/util/status-icons.ts +54 -0
- package/dist/types/src/components/TaskList/dnd.d.ts +0 -27
- package/dist/types/src/components/TaskList/dnd.d.ts.map +0 -1
- package/src/components/TaskList/dnd.ts +0 -46
package/dist/lib/index.mjs
CHANGED
|
@@ -1,58 +1,249 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
-
import { useObject } from "@dxos/echo-react";
|
|
10
|
-
import { Icon, IconButton, Input, Tag, composable, composableProps, useTranslation } from "@dxos/react-ui";
|
|
11
|
-
import { Listbox, TreeDropIndicator, TreeItemToggle, paddingIndentation, useListDisclosure } from "@dxos/react-ui-list";
|
|
12
|
-
import { MarkdownView } from "@dxos/react-ui-markdown";
|
|
13
|
-
import { mx } from "@dxos/ui-theme";
|
|
1
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import * as Hooks from "@dxos/react-ui/Hooks";
|
|
3
|
+
import * as Icon from "@dxos/react-ui/Icon";
|
|
4
|
+
import * as Layout from "@dxos/react-ui/Layout";
|
|
5
|
+
import * as Typography from "@dxos/react-ui/Typography";
|
|
6
|
+
import { Person, PullRequest, RemoteSession, Task } from "@dxos/types";
|
|
7
|
+
import { getHashHue, getStyles, mx, toHue } from "@dxos/ui-theme";
|
|
14
8
|
import { translationKey } from "#translations";
|
|
15
|
-
import { jsx, jsxs } from "react/jsx-runtime";
|
|
16
|
-
|
|
9
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
import { Filter, Obj, Ref, Tag } from "@dxos/echo";
|
|
11
|
+
import { useObject, useQuery } from "@dxos/echo-react";
|
|
12
|
+
import { Tree, createStaticTreeModel } from "@dxos/react-ui-list";
|
|
13
|
+
import { ActionMenu, createMenuAction, executeMenuAction, fallbackIcon } from "@dxos/react-ui-menu";
|
|
14
|
+
import * as Button from "@dxos/react-ui/Button";
|
|
15
|
+
import * as Tag$1 from "@dxos/react-ui/Tag";
|
|
16
|
+
import * as Theme from "@dxos/react-ui/Theme";
|
|
17
|
+
import * as Util from "@dxos/react-ui/Util";
|
|
18
|
+
import { MarkdownEditable, MarkdownView } from "@dxos/react-ui-markdown";
|
|
19
|
+
import { log } from "@dxos/log";
|
|
20
|
+
import * as Field from "@dxos/react-ui/Field";
|
|
21
|
+
import * as Input from "@dxos/react-ui/Input";
|
|
22
|
+
import * as SystemButton from "@dxos/react-ui/SystemButton";
|
|
23
|
+
import { submitOnModEnter } from "@dxos/ui-editor";
|
|
24
|
+
import * as Tooltip from "@dxos/react-ui/Tooltip";
|
|
25
|
+
import { RegistryContext } from "@effect/atom-react/RegistryContext";
|
|
26
|
+
import { DX_POPOVER_CONTENT_ATTR, DxAnchorActivate } from "@dxos/ui-types";
|
|
27
|
+
import { differenceInCalendarDays, formatDistance } from "date-fns";
|
|
28
|
+
//#region src/components/task-grid.ts
|
|
17
29
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
30
|
+
* The geometry a task's sections share: a fixed glyph column, then the content.
|
|
31
|
+
*
|
|
32
|
+
* Shared as a class list rather than as one grid, because the sections do not nest — properties,
|
|
33
|
+
* questions and history each own their own element and could not subgrid onto a common parent
|
|
34
|
+
* without becoming one component. A fixed first column is what makes them line up anyway: 24px is
|
|
35
|
+
* 24px in every section, where an `auto` or `min-content` column would size to each section's own
|
|
36
|
+
* widest glyph and land them a pixel or two apart.
|
|
21
37
|
*/
|
|
22
|
-
var
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
var TASK_GRID = "grid grid-cols-[1.5rem_1fr] gap-x-1 items-start";
|
|
39
|
+
/** Content in the second column: a row with no glyph of its own (a question's options, its context). */
|
|
40
|
+
var TASK_GRID_CONTENT = "col-start-2";
|
|
41
|
+
/**
|
|
42
|
+
* The glyph cell: one line box tall, so what it centres is the centre of its row's FIRST line — a
|
|
43
|
+
* cell as tall as the row would float the glyph down a wrapped paragraph.
|
|
44
|
+
*/
|
|
45
|
+
var TASK_GRID_ICON = "col-start-1 grid place-items-center h-[1lh]";
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/util/status-icons.ts
|
|
48
|
+
/**
|
|
49
|
+
* Every status, most active first — the order the picker offers and the order groups render in.
|
|
50
|
+
*
|
|
51
|
+
* It must stay exhaustive: `buildStatusGroups` renders only the statuses named here, so a status
|
|
52
|
+
* left off this list would take its tasks out of a grouped view entirely rather than merely sorting
|
|
53
|
+
* them late.
|
|
54
|
+
*/
|
|
55
|
+
var STATUS_ORDER = Task.StatusOptions.map(({ id }) => id);
|
|
56
|
+
/**
|
|
57
|
+
* The glyph for a field with no value set, whatever the field.
|
|
58
|
+
*
|
|
59
|
+
* A dot rather than a dash or an empty cell: every trailing control renders on every row so setting
|
|
60
|
+
* a value never depends on discovering a hover affordance, which means an unset one still has to
|
|
61
|
+
* occupy its column and read as a control. One glyph across priority, estimate and anything added
|
|
62
|
+
* later is what keeps a column of unset rows reading as one column.
|
|
63
|
+
*/
|
|
64
|
+
var UNSET_ICON = "ph--asterisk--regular";
|
|
65
|
+
var priorityIcon = (priority) => priority != null ? PRIORITY_ICONS.get(priority) ?? "ph--asterisk--regular" : UNSET_ICON;
|
|
66
|
+
/**
|
|
67
|
+
* A status's glyph, from the same table that gives it a title and a hue, so the flat row, the tree,
|
|
68
|
+
* the picker and the form's select cannot drift apart.
|
|
69
|
+
*/
|
|
70
|
+
var statusIcon = (status) => STATUS_ICONS.get(status) ?? "ph--asterisk--regular";
|
|
71
|
+
/**
|
|
72
|
+
* Hue lookup over a schema option table.
|
|
73
|
+
*
|
|
74
|
+
* Colour comes from the table rather than from a map of our own so a row, its picker and the form's
|
|
75
|
+
* select all paint a value the same way; `getStyles` falls back to neutral for a colour outside the
|
|
76
|
+
* palette (the tables use `gray`, which is not one).
|
|
77
|
+
*/
|
|
78
|
+
var textStyleFor = (options) => {
|
|
79
|
+
const colors = new Map(options.map(({ id, color }) => [id, color]));
|
|
80
|
+
return (id) => getStyles(colors.get(id) ?? "neutral").text;
|
|
81
|
+
};
|
|
82
|
+
var statusTextStyle = textStyleFor(Task.StatusOptions);
|
|
83
|
+
var priorityTextStyle = textStyleFor(Task.PriorityOptions);
|
|
84
|
+
var estimateTextStyle = textStyleFor(Task.EstimateOptions);
|
|
85
|
+
/** A priority's glyph, from the same table that gives it a title and a hue. */
|
|
86
|
+
var PRIORITY_ICONS = new Map(Task.PriorityOptions.map(({ id, icon }) => [id, icon]));
|
|
87
|
+
var STATUS_ICONS = new Map(Task.StatusOptions.map(({ id, icon }) => [id, icon]));
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/components/TaskHistory/TaskHistory.tsx
|
|
90
|
+
var EVENT_ICONS = {
|
|
91
|
+
created: {
|
|
92
|
+
icon: "ph--plant--regular",
|
|
93
|
+
hue: "emerald"
|
|
94
|
+
},
|
|
95
|
+
updated: {
|
|
96
|
+
icon: "ph--pencil-simple--regular",
|
|
97
|
+
hue: "indigo"
|
|
98
|
+
},
|
|
99
|
+
question: {
|
|
100
|
+
icon: "ph--question--regular",
|
|
101
|
+
hue: "amber"
|
|
102
|
+
},
|
|
103
|
+
answer: {
|
|
104
|
+
icon: "ph--check-circle--regular",
|
|
105
|
+
hue: "emerald"
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
/** The line an entry reads as: a change's own note. */
|
|
109
|
+
var entryText = (entry) => entry.event === "question" ? entry.text : entry.event === "answer" ? entry.answer : entry.description ?? entry.event;
|
|
110
|
+
/** Falls back to the unset glyph: an entry written by an older schema still renders as a row. */
|
|
111
|
+
var eventIcon = (event) => EVENT_ICONS[event] ?? {
|
|
112
|
+
icon: "ph--asterisk--regular",
|
|
113
|
+
hue: "neutral"
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* The log as the reader sees it, newest first. A question is an item only once answered — an open
|
|
117
|
+
* one is waiting on the reader and belongs with the task's open questions, not in its record — and
|
|
118
|
+
* then question and answer are one item, dated when the exchange closed.
|
|
119
|
+
*/
|
|
120
|
+
var buildItems = (entries, limit) => {
|
|
121
|
+
const threads = new Map(Task.getQuestions(entries).map((thread) => [thread.question.id, thread]));
|
|
122
|
+
const items = [];
|
|
123
|
+
for (const [index, entry] of [...entries].reverse().entries()) {
|
|
124
|
+
if (Task.isQuestionEntry(entry)) continue;
|
|
125
|
+
const thread = Task.isAnswerEntry(entry) ? threads.get(entry.questionId) : void 0;
|
|
126
|
+
if (thread && thread.answer === entry) {
|
|
127
|
+
const { icon, hue } = eventIcon("question");
|
|
128
|
+
items.push({
|
|
129
|
+
key: entry.id,
|
|
130
|
+
icon,
|
|
131
|
+
hue: getStyles(hue).text,
|
|
132
|
+
text: thread.question.text,
|
|
133
|
+
answer: entry.answer,
|
|
134
|
+
date: entry.date
|
|
37
135
|
});
|
|
38
|
-
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const { icon, hue } = eventIcon(entry.event);
|
|
139
|
+
items.push({
|
|
140
|
+
key: entry.id ?? `${entry.date}-${index}`,
|
|
141
|
+
icon,
|
|
142
|
+
hue: getStyles(hue).text,
|
|
143
|
+
text: entryText(entry),
|
|
144
|
+
date: entry.date
|
|
39
145
|
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return rows;
|
|
146
|
+
}
|
|
147
|
+
return items.slice(0, limit);
|
|
43
148
|
};
|
|
44
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* A task's activity log, newest first.
|
|
151
|
+
*
|
|
152
|
+
* Each entry already carries the human-readable record of what happened, so a line is that sentence
|
|
153
|
+
* plus when it happened — the pane adds no interpretation of its own. An answered question reads as
|
|
154
|
+
* one line with its answer under it; an open one is not part of the record yet.
|
|
155
|
+
*/
|
|
156
|
+
var TaskHistory = ({ entries, limit = 5, classNames }) => {
|
|
157
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
158
|
+
const items = useMemo(() => buildItems(entries, limit), [entries, limit]);
|
|
159
|
+
if (items.length === 0) return null;
|
|
160
|
+
return /* @__PURE__ */ jsx(Layout.Container, {
|
|
161
|
+
asChild: true,
|
|
162
|
+
gutter: "inherit",
|
|
163
|
+
gap: "sm",
|
|
164
|
+
role: "list",
|
|
165
|
+
"aria-label": t("task-history.label"),
|
|
166
|
+
"data-testid": "taskList.history",
|
|
167
|
+
classNames: mx("text-sm text-fg-muted", classNames),
|
|
168
|
+
children: /* @__PURE__ */ jsxs("section", { children: [/* @__PURE__ */ jsx(Typography.Text, {
|
|
169
|
+
asChild: true,
|
|
170
|
+
tone: "subtle",
|
|
171
|
+
classNames: "dx-label py-0",
|
|
172
|
+
children: /* @__PURE__ */ jsx("h2", { children: t("task-history.label") })
|
|
173
|
+
}), items.map((item) => /* @__PURE__ */ jsxs("div", {
|
|
174
|
+
role: "listitem",
|
|
175
|
+
className: mx(TASK_GRID, "min-w-0"),
|
|
176
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
177
|
+
className: TASK_GRID_ICON,
|
|
178
|
+
children: /* @__PURE__ */ jsx(Icon.Icon, {
|
|
179
|
+
icon: item.icon,
|
|
180
|
+
classNames: item.hue,
|
|
181
|
+
size: "md"
|
|
182
|
+
})
|
|
183
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
184
|
+
className: "flex gap-2 min-w-0",
|
|
185
|
+
children: [/* @__PURE__ */ jsxs("span", {
|
|
186
|
+
className: "grow min-w-0",
|
|
187
|
+
children: [item.text, item.answer && /* @__PURE__ */ jsx("span", {
|
|
188
|
+
className: "block text-fg",
|
|
189
|
+
"data-testid": "taskList.history.answer",
|
|
190
|
+
children: item.answer
|
|
191
|
+
})]
|
|
192
|
+
}), /* @__PURE__ */ jsx(Typography.Timestamp, {
|
|
193
|
+
date: item.date,
|
|
194
|
+
classNames: "shrink-0 text-right"
|
|
195
|
+
})]
|
|
196
|
+
})]
|
|
197
|
+
}, item.key))] })
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
TaskHistory.displayName = "TaskList.History";
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/components/TaskList/hierarchy.ts
|
|
203
|
+
/**
|
|
204
|
+
* The tree the list renders, and where a gesture puts a task in it. Kept out of the components
|
|
205
|
+
* because this is the part a regression would break: a drop cannot be driven synthetically
|
|
206
|
+
* (pragmatic-drag-and-drop uses native HTML5 drag events), so the placement calculation is tested
|
|
207
|
+
* directly instead.
|
|
208
|
+
*
|
|
209
|
+
* `TaskSet.tasks` lists the roots and each `Task.subtasks` its own children, so every list orders
|
|
210
|
+
* one set of siblings only: a parent moves without touching its subtree's entries, and a peer
|
|
211
|
+
* reordering one branch cannot corrupt another.
|
|
212
|
+
*/
|
|
213
|
+
/** One rendered row: the task, its depth, and where it sits among its siblings. */
|
|
45
214
|
var subtreeIds = (tasks, task) => {
|
|
215
|
+
const childrenOf = Task.childIndex(tasks);
|
|
46
216
|
const ids = /* @__PURE__ */ new Set();
|
|
47
217
|
const visit = (current) => {
|
|
48
218
|
if (ids.has(current.id)) return;
|
|
49
219
|
ids.add(current.id);
|
|
50
|
-
for (const child of
|
|
220
|
+
for (const child of childrenOf(current)) visit(child);
|
|
51
221
|
};
|
|
52
222
|
visit(task);
|
|
53
223
|
return ids;
|
|
54
224
|
};
|
|
55
225
|
/**
|
|
226
|
+
* Places a task after `target`'s ancestor `levels` steps up — the tree hitbox's `reparent`, which
|
|
227
|
+
* is how a drop past the last child escapes the subtree it would otherwise join. `levels` of 1 is
|
|
228
|
+
* "after my parent", 2 "after my grandparent", and so on; running out of ancestors rejects rather
|
|
229
|
+
* than silently landing at the root.
|
|
230
|
+
*/
|
|
231
|
+
var resolveReparent = (tasks, source, target, levels) => {
|
|
232
|
+
if (levels < 1) return;
|
|
233
|
+
let ancestor = target;
|
|
234
|
+
for (let step = 0; step < levels; step++) {
|
|
235
|
+
const parentId = ancestor && Task.parentTaskId(ancestor);
|
|
236
|
+
ancestor = parentId === void 0 ? void 0 : tasks.find((task) => task.id === parentId);
|
|
237
|
+
if (!ancestor) return;
|
|
238
|
+
}
|
|
239
|
+
return subtreeIds(tasks, source).has(ancestor.id) ? void 0 : resolveTaskPlacement({
|
|
240
|
+
tasks,
|
|
241
|
+
source,
|
|
242
|
+
target: ancestor,
|
|
243
|
+
intent: "reorder-below"
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
56
247
|
* Resolves a drop into a single `MoveTask` call, or `undefined` when the gesture is rejected.
|
|
57
248
|
*
|
|
58
249
|
* Rejected rather than silently ignored, so the caller can say no with the cursor:
|
|
@@ -65,11 +256,11 @@ var resolveTaskPlacement = ({ tasks, source, target, intent }) => {
|
|
|
65
256
|
if (subtreeIds(tasks, source).has(target.id)) return;
|
|
66
257
|
if (intent === "make-child") return {
|
|
67
258
|
parentTask: target,
|
|
68
|
-
before:
|
|
259
|
+
before: Task.subTasks(tasks, target).find((child) => child.id !== source.id)
|
|
69
260
|
};
|
|
70
|
-
const parentId =
|
|
261
|
+
const parentId = Task.parentTaskId(target);
|
|
71
262
|
const parent = parentId === void 0 ? null : tasks.find((task) => task.id === parentId) ?? null;
|
|
72
|
-
const siblings = parent === null ?
|
|
263
|
+
const siblings = parent === null ? Task.rootTasks(tasks) : Task.subTasks(tasks, parent);
|
|
73
264
|
const index = siblings.findIndex((task) => task.id === target.id);
|
|
74
265
|
if (intent === "reorder-above") return {
|
|
75
266
|
parentTask: parent,
|
|
@@ -82,9 +273,9 @@ var resolveTaskPlacement = ({ tasks, source, target, intent }) => {
|
|
|
82
273
|
};
|
|
83
274
|
/** A task's siblings (in sibling order) and its index among them. */
|
|
84
275
|
var siblingsOf = (tasks, task) => {
|
|
85
|
-
const parentId =
|
|
276
|
+
const parentId = Task.parentTaskId(task);
|
|
86
277
|
const parent = parentId === void 0 ? null : tasks.find((candidate) => candidate.id === parentId) ?? null;
|
|
87
|
-
const siblings = parent === null ?
|
|
278
|
+
const siblings = parent === null ? Task.rootTasks(tasks) : Task.subTasks(tasks, parent);
|
|
88
279
|
return {
|
|
89
280
|
parent,
|
|
90
281
|
siblings,
|
|
@@ -132,417 +323,841 @@ var resolveNudge = (tasks, task, direction) => {
|
|
|
132
323
|
} : void 0;
|
|
133
324
|
};
|
|
134
325
|
//#endregion
|
|
135
|
-
//#region src/
|
|
136
|
-
/**
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
var
|
|
142
|
-
/**
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*/
|
|
147
|
-
var
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
326
|
+
//#region src/util/assignee.ts
|
|
327
|
+
/** The glyph for a person assignee. */
|
|
328
|
+
var PERSON_ICON = "ph--user--regular";
|
|
329
|
+
/** A DID is long and mostly shared prefix, so the start is the part that tells two apart. */
|
|
330
|
+
var shortDid = (did) => `${did.slice(0, 12)}…`;
|
|
331
|
+
/** An id's tail rather than its head: object ids share a timestamp prefix and harness ids a `session_` one. */
|
|
332
|
+
var shortId = (id) => `…${id.slice(-8)}`;
|
|
333
|
+
/**
|
|
334
|
+
* How an assignee reads wherever a task names it. An assistant's actor stands for a session rather
|
|
335
|
+
* than a person, so its name falls through the session's own fields before the actor's, and it
|
|
336
|
+
* always has one — an agent that reads as "Agent" says an assistant holds the task but never which.
|
|
337
|
+
*/
|
|
338
|
+
var getAssigneeDisplay = ({ assignee, contact, subject, agentLabel }) => {
|
|
339
|
+
const session = Obj.instanceOf(RemoteSession.RemoteSession, subject) ? subject : void 0;
|
|
340
|
+
const agent = assignee.role === "assistant";
|
|
341
|
+
const sessionId = (session && RemoteSession.getSessionId(session)) ?? (session || subject === void 0 ? Task.refEntityId(assignee.subject) : void 0);
|
|
342
|
+
return {
|
|
343
|
+
label: contact?.fullName ?? session?.title ?? (session && RemoteSession.harnessName(session)) ?? assignee.name ?? assignee.email ?? (assignee.identityDid ? shortDid(assignee.identityDid) : void 0) ?? (sessionId ? shortId(sessionId) : void 0) ?? (agent ? agentLabel : void 0),
|
|
344
|
+
icon: (session && RemoteSession.harnessIcon(session)) ?? (agent ? "ph--robot--regular" : "ph--user--regular"),
|
|
345
|
+
agent,
|
|
346
|
+
session
|
|
347
|
+
};
|
|
156
348
|
};
|
|
157
349
|
//#endregion
|
|
158
|
-
//#region src/
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
350
|
+
//#region src/hooks/useAssigneeDisplay.ts
|
|
351
|
+
/**
|
|
352
|
+
* The label and glyph for a task's assignee, resolving its contact and subject refs — shared by the
|
|
353
|
+
* list's pill and the properties row so the two cannot name the same assignee differently.
|
|
354
|
+
*/
|
|
355
|
+
var useAssigneeDisplay = (assignee) => {
|
|
356
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
357
|
+
const [contact] = useObject(assignee?.contact);
|
|
358
|
+
const [subject] = useObject(assignee?.subject);
|
|
359
|
+
if (!assignee) return {
|
|
360
|
+
icon: PERSON_ICON,
|
|
361
|
+
agent: false
|
|
362
|
+
};
|
|
363
|
+
return getAssigneeDisplay({
|
|
364
|
+
assignee,
|
|
365
|
+
contact,
|
|
366
|
+
subject,
|
|
367
|
+
agentLabel: t("assignee-agent.label")
|
|
368
|
+
});
|
|
369
|
+
};
|
|
370
|
+
//#endregion
|
|
371
|
+
//#region src/components/TaskList/TaskDescription.tsx
|
|
372
|
+
/** A description is a line in a row, not a document: no paragraph block, no heading scale. */
|
|
373
|
+
var DESCRIPTION_COMPONENTS = { p: ({ children }) => /* @__PURE__ */ jsx("span", { children }) };
|
|
374
|
+
/**
|
|
375
|
+
* A task's description as it appears in a row. Shared by the flat list and the tree so the two
|
|
376
|
+
* paths cannot drift on type scale or clamping; only the placement differs, which is the caller's
|
|
377
|
+
* to supply — the flat row puts it in its own subgrid cell, the tree stacks it under the title
|
|
378
|
+
* inside the heading.
|
|
379
|
+
*/
|
|
380
|
+
var TaskDescription = ({ content, components, classNames }) => /* @__PURE__ */ jsx(MarkdownView, {
|
|
381
|
+
"data-testid": "taskList.item.description",
|
|
382
|
+
content,
|
|
383
|
+
classNames: mx("text-sm text-fg-muted line-clamp-3", classNames),
|
|
384
|
+
uniformLineHeight: true,
|
|
385
|
+
components: {
|
|
386
|
+
...DESCRIPTION_COMPONENTS,
|
|
387
|
+
...components
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
var [TaskListProvider, useTaskListContext] = Hooks.createContext("TaskList.Root");
|
|
391
|
+
//#endregion
|
|
392
|
+
//#region src/components/TaskList/TaskRowCells.tsx
|
|
393
|
+
/** The status glyph, which is also the control that completes the task. */
|
|
394
|
+
var TaskStatusControl = ({ task, onTaskUpdate, active, classNames }) => {
|
|
395
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
396
|
+
const status = task.status ?? "todo";
|
|
397
|
+
const { icon, classNames: iconClassNames } = active ?? Task.isAgentWorking(task) ? {
|
|
398
|
+
icon: "ph--spinner--regular",
|
|
399
|
+
classNames: "text-info-text animate-spin"
|
|
400
|
+
} : {
|
|
401
|
+
icon: statusIcon(status),
|
|
402
|
+
classNames: statusTextStyle(status)
|
|
403
|
+
};
|
|
404
|
+
const actions = useCallback(() => Task.StatusOptions.map(({ id }) => createMenuAction(`status-${id}`, () => onTaskUpdate?.(task, { status: id }), {
|
|
405
|
+
label: t(`status-${id}.label`),
|
|
406
|
+
icon: statusIcon(id),
|
|
407
|
+
iconClassNames: statusTextStyle(id),
|
|
408
|
+
checked: status === id
|
|
409
|
+
})), [
|
|
212
410
|
onTaskUpdate,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
411
|
+
task,
|
|
412
|
+
status,
|
|
413
|
+
t
|
|
414
|
+
]);
|
|
415
|
+
if (!onTaskUpdate) return /* @__PURE__ */ jsxs(Layout.Block, {
|
|
416
|
+
"aria-hidden": false,
|
|
417
|
+
"data-testid": "taskList.item.status",
|
|
418
|
+
classNames,
|
|
419
|
+
children: [/* @__PURE__ */ jsx(Icon.Icon, {
|
|
420
|
+
icon,
|
|
421
|
+
classNames: iconClassNames
|
|
422
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
423
|
+
className: "sr-only",
|
|
424
|
+
children: t(`status-${status}.label`)
|
|
425
|
+
})]
|
|
426
|
+
});
|
|
427
|
+
const trigger = /* @__PURE__ */ jsx(Button.Root, {
|
|
428
|
+
"data-testid": "taskList.item.status",
|
|
429
|
+
iconClassNames,
|
|
430
|
+
variant: "ghost",
|
|
431
|
+
icon,
|
|
432
|
+
iconOnly: true,
|
|
433
|
+
label: t("task-status.label"),
|
|
434
|
+
onClick: (event) => event.stopPropagation()
|
|
435
|
+
});
|
|
436
|
+
return /* @__PURE__ */ jsx(Layout.Block, {
|
|
437
|
+
classNames,
|
|
438
|
+
children: /* @__PURE__ */ jsx(ActionMenu, {
|
|
439
|
+
deferUntilOpen: true,
|
|
440
|
+
actions,
|
|
441
|
+
children: trigger
|
|
223
442
|
})
|
|
224
443
|
});
|
|
225
444
|
};
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
445
|
+
TaskStatusControl.displayName = "TaskList.StatusControl";
|
|
446
|
+
/**
|
|
447
|
+
* The task's reference: a button in the task's mnemonic hue that copies it. With an ordinal it shows the number, and
|
|
448
|
+
* the clipboard only on hover; without one, the clipboard. At least as wide as the clipboard button, so a one-digit
|
|
449
|
+
* number does not make a narrower target.
|
|
450
|
+
*
|
|
451
|
+
* Copies the task's full `echo://<space>/<id>` URI rather than the mnemonic it names: a mnemonic is only unique enough
|
|
452
|
+
* to read, while the URI resolves the task from anywhere it is pasted — a prompt, an MCP call, another space.
|
|
453
|
+
*/
|
|
454
|
+
var TaskMnemonic = ({ task, ordinal, classNames }) => {
|
|
455
|
+
const mnemonic = Obj.getMnemonic(task);
|
|
456
|
+
const [copied, setCopied] = useState(false);
|
|
457
|
+
const timeoutRef = useRef(void 0);
|
|
458
|
+
useEffect(() => () => clearTimeout(timeoutRef.current), []);
|
|
459
|
+
const handleClick = (event) => {
|
|
460
|
+
event.stopPropagation();
|
|
461
|
+
navigator.clipboard.writeText(Obj.getURI(task, { prefer: "absolute" }).toString()).then(() => {
|
|
462
|
+
setCopied(true);
|
|
463
|
+
clearTimeout(timeoutRef.current);
|
|
464
|
+
timeoutRef.current = setTimeout(() => setCopied(false), 1e3);
|
|
465
|
+
}).catch(() => setCopied(false));
|
|
466
|
+
};
|
|
467
|
+
const icon = /* @__PURE__ */ jsx(Icon.Icon, { icon: copied ? "ph--check--regular" : "ph--clipboard--regular" });
|
|
468
|
+
return /* @__PURE__ */ jsx(Tooltip.Trigger, {
|
|
469
|
+
asChild: true,
|
|
470
|
+
content: mnemonic,
|
|
471
|
+
children: /* @__PURE__ */ jsx(Button.Root, {
|
|
472
|
+
size: "sm",
|
|
473
|
+
compact: true,
|
|
474
|
+
hue: getHashHue(mnemonic),
|
|
475
|
+
"aria-label": mnemonic,
|
|
476
|
+
"data-testid": "taskList.item.mnemonic",
|
|
477
|
+
classNames: mx("group justify-center min-w-(--dx-control-size) font-mono tabular-nums", classNames),
|
|
478
|
+
onClick: handleClick,
|
|
479
|
+
children: ordinal === void 0 || copied ? icon : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
|
|
480
|
+
"data-testid": "taskList.item.ordinal",
|
|
481
|
+
className: "group-hover:hidden",
|
|
482
|
+
children: ordinal
|
|
483
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
484
|
+
className: "hidden group-hover:contents",
|
|
485
|
+
children: icon
|
|
486
|
+
})] })
|
|
487
|
+
})
|
|
234
488
|
});
|
|
235
|
-
}
|
|
236
|
-
|
|
489
|
+
};
|
|
490
|
+
TaskMnemonic.displayName = "TaskList.Mnemonic";
|
|
237
491
|
/**
|
|
238
|
-
* The rows
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
*/
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const collapsed = useMemo(() => new Set(tasks.map((task) => task.id).filter(isCollapsed)), [tasks, isCollapsed]);
|
|
255
|
-
const rows = useMemo(() => hierarchical ? walkTaskTree(tasks, collapsed) : void 0, [
|
|
256
|
-
hierarchical,
|
|
257
|
-
tasks,
|
|
258
|
-
collapsed
|
|
259
|
-
]);
|
|
260
|
-
const groups = useMemo(() => {
|
|
261
|
-
if (!groupByStatus) return tasks.length > 0 ? [{
|
|
262
|
-
status: void 0,
|
|
263
|
-
tasks
|
|
264
|
-
}] : [];
|
|
265
|
-
return STATUS_ORDER.map((status) => ({
|
|
266
|
-
status,
|
|
267
|
-
tasks: tasks.filter((task) => (task.status ?? "todo") === status)
|
|
268
|
-
})).filter((group) => group.tasks.length > 0);
|
|
269
|
-
}, [tasks, groupByStatus]);
|
|
270
|
-
return /* @__PURE__ */ jsx(Listbox.Content, {
|
|
271
|
-
...composableProps(props, { classNames: mx("group grid gap-x-1 w-full min-w-0", showDescriptions ? "auto-rows-min items-start" : "auto-rows-[2rem] items-center", showGutter ? GRID_COLS.contentWithOrdinals : GRID_COLS.content) }),
|
|
272
|
-
"aria-label": "Tasks",
|
|
273
|
-
ref: forwardedRef,
|
|
274
|
-
children: rows ? rows.map((row) => /* @__PURE__ */ jsx(TaskListItem, {
|
|
275
|
-
task: row.task,
|
|
276
|
-
ordinal: showOrdinals ? ordinals.get(row.task.id) : void 0,
|
|
277
|
-
row
|
|
278
|
-
}, row.task.id)) : groups.map(({ status, tasks }) => /* @__PURE__ */ jsxs(Fragment, { children: [status && showGroupLabels && /* @__PURE__ */ jsx(TaskListGroupLabel, { children: statusLabel(status) }), tasks.map((task) => /* @__PURE__ */ jsx(TaskListItem, {
|
|
279
|
-
task,
|
|
280
|
-
ordinal: showOrdinals ? ordinals.get(task.id) : void 0
|
|
281
|
-
}, task.id))] }, status ?? "all"))
|
|
492
|
+
* The gutter's checkbox: which rows an action will act on, never a status write — completing a task
|
|
493
|
+
* is what the status control does. It takes the ordinal's cell rather than a column of its own, so a
|
|
494
|
+
* list that offers it keeps one row geometry and the trailing controls do not shift.
|
|
495
|
+
*/
|
|
496
|
+
var TaskCheckbox = ({ task, checked, onCheckedChange, classNames }) => {
|
|
497
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
498
|
+
return /* @__PURE__ */ jsx(Layout.Block, {
|
|
499
|
+
"aria-hidden": false,
|
|
500
|
+
classNames,
|
|
501
|
+
children: /* @__PURE__ */ jsx(Field.Root, { children: /* @__PURE__ */ jsx(Input.Checkbox, {
|
|
502
|
+
checked,
|
|
503
|
+
"data-testid": "taskList.item.checkbox",
|
|
504
|
+
"aria-label": t("task-check.label"),
|
|
505
|
+
onCheckedChange: () => onCheckedChange(task),
|
|
506
|
+
onClick: (event) => event.stopPropagation()
|
|
507
|
+
}) })
|
|
282
508
|
});
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
509
|
+
};
|
|
510
|
+
TaskCheckbox.displayName = "TaskList.Checkbox";
|
|
511
|
+
/**
|
|
512
|
+
* Estimate as its own label rather than a glyph: the sizes are a vocabulary a reader already knows
|
|
513
|
+
* (`XS`…`XL`), and two ordinal ramps side by side would be read as one. Rendered on every row so
|
|
514
|
+
* setting an estimate never depends on discovering a hover affordance, and falling back to
|
|
515
|
+
* {@link UNSET_ICON} when unset — the same dot the priority column shows, so a row with neither set
|
|
516
|
+
* reads as two empty controls rather than a dash beside a dot.
|
|
517
|
+
*/
|
|
518
|
+
var TaskEstimateControl = ({ task, classNames }) => {
|
|
519
|
+
const { onTaskUpdate } = useTaskListContext("TaskList.EstimateControl");
|
|
520
|
+
return /* @__PURE__ */ jsx(TaskEstimatePicker, {
|
|
521
|
+
estimate: task.estimate,
|
|
522
|
+
onChange: onTaskUpdate && ((estimate) => onTaskUpdate(task, { estimate: estimate ?? null })),
|
|
523
|
+
testId: "taskList.item.estimate",
|
|
524
|
+
classNames
|
|
292
525
|
});
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
* plus the cell's `gap-1`. The description sits in a different grid row and has to clear the same
|
|
300
|
-
* distance to line up under the title.
|
|
301
|
-
*/
|
|
302
|
-
var TOGGLE_INSET = "1.75rem";
|
|
303
|
-
var useTaskDrag = ({ task, row, tasks, onTaskMove, isCollapsed, onCollapseToggle, onDraggingChange }) => {
|
|
304
|
-
const rowRef = useRef(null);
|
|
305
|
-
const dragHandleRef = useRef(null);
|
|
306
|
-
const [instruction, setInstruction] = useState(null);
|
|
307
|
-
const expandTimeout = useRef(null);
|
|
308
|
-
const liftFrame = useRef(null);
|
|
309
|
-
const latest = useRef({
|
|
310
|
-
tasks,
|
|
311
|
-
onTaskMove,
|
|
312
|
-
isCollapsed,
|
|
313
|
-
onCollapseToggle,
|
|
314
|
-
onDraggingChange,
|
|
315
|
-
row
|
|
526
|
+
};
|
|
527
|
+
/** The estimate label and its menu over a bare value, for a row's task or a draft that has none yet. */
|
|
528
|
+
var TaskEstimatePicker = ({ estimate, onChange, testId, classNames }) => {
|
|
529
|
+
const label = estimate?.toUpperCase() ?? /* @__PURE__ */ jsx(Icon.Icon, {
|
|
530
|
+
icon: "ph--asterisk--regular",
|
|
531
|
+
classNames: "text-neutral-500"
|
|
316
532
|
});
|
|
317
|
-
|
|
533
|
+
if (!onChange) return /* @__PURE__ */ jsx(Layout.Block, {
|
|
534
|
+
classNames: mx(estimateTextStyle(estimate), classNames),
|
|
535
|
+
children: label
|
|
536
|
+
});
|
|
537
|
+
return /* @__PURE__ */ jsx(Layout.Block, {
|
|
538
|
+
classNames,
|
|
539
|
+
children: /* @__PURE__ */ jsx(ActionMenu, {
|
|
540
|
+
deferUntilOpen: true,
|
|
541
|
+
actions: () => [Task.NullOption, ...Task.EstimateOptions].map(({ id, title }) => createMenuAction(`estimate-${id}`, () => onChange(id === "none" ? void 0 : id), {
|
|
542
|
+
label: title,
|
|
543
|
+
checked: (estimate ?? "none") === id
|
|
544
|
+
})),
|
|
545
|
+
children: /* @__PURE__ */ jsx(Button.Root, {
|
|
546
|
+
variant: "ghost",
|
|
547
|
+
"data-testid": testId,
|
|
548
|
+
classNames: mx("w-8 px-0 text-xs tabular-nums", estimateTextStyle(estimate)),
|
|
549
|
+
onClick: (event) => event.stopPropagation(),
|
|
550
|
+
children: label
|
|
551
|
+
})
|
|
552
|
+
})
|
|
553
|
+
});
|
|
554
|
+
};
|
|
555
|
+
TaskEstimateControl.displayName = "TaskList.EstimateControl";
|
|
556
|
+
/**
|
|
557
|
+
* Priority as a signal-strength glyph rather than a word: the four levels are ordinal, so a ramp
|
|
558
|
+
* reads at a glance where four differently-worded tags do not. `urgent` breaks the ramp deliberately
|
|
559
|
+
* — it is a different kind of statement from "how much", and a filled mark carries that.
|
|
560
|
+
*
|
|
561
|
+
* The glyph is also the control: it opens a menu to set the level. It renders on every row —
|
|
562
|
+
* including one with no priority, which shows a dot — so setting a priority never depends on
|
|
563
|
+
* discovering a hover affordance.
|
|
564
|
+
*/
|
|
565
|
+
var TaskPriorityIcon = ({ task, classNames }) => {
|
|
566
|
+
const { onTaskUpdate } = useTaskListContext("TaskList.PriorityIcon");
|
|
567
|
+
return /* @__PURE__ */ jsx(TaskPriorityPicker, {
|
|
568
|
+
priority: task.priority ?? void 0,
|
|
569
|
+
onChange: onTaskUpdate && ((priority) => onTaskUpdate(task, { priority: priority ?? null })),
|
|
570
|
+
testId: "taskList.item.priority",
|
|
571
|
+
classNames
|
|
572
|
+
});
|
|
573
|
+
};
|
|
574
|
+
/** The priority glyph and its menu over a bare value, for a row's task or a draft that has none yet. */
|
|
575
|
+
var TaskPriorityPicker = ({ priority, onChange, testId, classNames }) => {
|
|
576
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
577
|
+
const icon = priorityIcon(priority);
|
|
578
|
+
const styles = priorityTextStyle(priority);
|
|
579
|
+
if (!onChange) return /* @__PURE__ */ jsx(Layout.Block, {
|
|
580
|
+
classNames,
|
|
581
|
+
children: /* @__PURE__ */ jsx(Icon.Icon, {
|
|
582
|
+
icon,
|
|
583
|
+
classNames: mx(styles)
|
|
584
|
+
})
|
|
585
|
+
});
|
|
586
|
+
return /* @__PURE__ */ jsx(Layout.Block, {
|
|
587
|
+
classNames,
|
|
588
|
+
children: /* @__PURE__ */ jsx(ActionMenu, {
|
|
589
|
+
deferUntilOpen: true,
|
|
590
|
+
actions: () => [Task.NullOption, ...Task.PriorityOptions].map(({ id, icon: optionIcon }) => createMenuAction(`priority-${id}`, () => onChange(id === "none" ? void 0 : id), {
|
|
591
|
+
label: t(`priority-${id}.label`),
|
|
592
|
+
icon: optionIcon ?? "ph--asterisk--regular",
|
|
593
|
+
iconClassNames: priorityTextStyle(id),
|
|
594
|
+
checked: (priority ?? "none") === id
|
|
595
|
+
})),
|
|
596
|
+
children: /* @__PURE__ */ jsx(Button.Root, {
|
|
597
|
+
variant: "ghost",
|
|
598
|
+
icon,
|
|
599
|
+
iconOnly: true,
|
|
600
|
+
label: t("task-priority.label"),
|
|
601
|
+
"data-testid": testId,
|
|
602
|
+
iconClassNames: styles,
|
|
603
|
+
onClick: (event) => event.stopPropagation()
|
|
604
|
+
})
|
|
605
|
+
})
|
|
606
|
+
});
|
|
607
|
+
};
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/components/TaskList/tracks.ts
|
|
610
|
+
/**
|
|
611
|
+
* A cell's place on the list's column template, by track name, on the title line. Shared by the tree's rows and the
|
|
612
|
+
* edit pane, which lay out on the same template, so a field sits exactly where the value it edits is read; a cell
|
|
613
|
+
* that is absent leaves its track empty. Literal so Tailwind finds the classes.
|
|
614
|
+
*/
|
|
615
|
+
var TRACK = {
|
|
616
|
+
gutter: "row-start-1 col-[gutter]",
|
|
617
|
+
status: "row-start-1 col-[status]",
|
|
618
|
+
title: "row-start-1 col-[title]",
|
|
619
|
+
artifacts: "row-start-1 col-[artifacts]",
|
|
620
|
+
assignee: "row-start-1 col-[assignee]",
|
|
621
|
+
estimate: "row-start-1 col-[estimate]",
|
|
622
|
+
priority: "row-start-1 col-[priority]",
|
|
623
|
+
actions: "row-start-1 col-[actions]"
|
|
624
|
+
};
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/components/TaskList/TaskListEditor.tsx
|
|
627
|
+
var __dxlog_file = "/__w/dxos/dxos/packages/ui/react-ui-task/src/components/TaskList/TaskListEditor.tsx";
|
|
628
|
+
/**
|
|
629
|
+
* The detail half of the list: it edits whichever task is selected, and creates one when none is.
|
|
630
|
+
*
|
|
631
|
+
* Editing lives here rather than in the row because a row is 32px of shared subgrid — a field
|
|
632
|
+
* opening inside it moves everything around it. A pane below the list has room to be a field.
|
|
633
|
+
*
|
|
634
|
+
* The fields, and nothing else. A task's questions and its history belong to the surface that has
|
|
635
|
+
* room to answer and to read — the detail article — and under a list they grew the strip by a line
|
|
636
|
+
* per entry, pushing the list itself off the screen.
|
|
637
|
+
*/
|
|
638
|
+
var TaskListEditor = Util.composable(({ placeholder, showDescription = false, descriptionPlaceholder, descriptionExtensions, createOnly = false, acceptFiles = false, ...props }, forwardedRef) => {
|
|
639
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
640
|
+
const { className, ...rest } = Util.composableProps(props);
|
|
641
|
+
const descriptionRef = useRef(null);
|
|
642
|
+
const { tasks, selected, columns, showEstimates, flush, onTaskCreate, onTaskUpdate, onTaskSelect } = useTaskListContext("TaskList.Editor");
|
|
643
|
+
const task = useMemo(() => createOnly ? void 0 : tasks.find(({ id }) => id === selected), [
|
|
644
|
+
createOnly,
|
|
318
645
|
tasks,
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
};
|
|
358
|
-
return combine(draggable({
|
|
359
|
-
element: handle,
|
|
360
|
-
getInitialData: () => data,
|
|
361
|
-
onGenerateDragPreview: ({ location, nativeSetDragImage }) => {
|
|
362
|
-
const rows = subtreeRows(element, latest.current.tasks, task);
|
|
363
|
-
const { left, top } = element.getBoundingClientRect();
|
|
364
|
-
const { clientX, clientY } = location.initial.input;
|
|
365
|
-
const offset = {
|
|
366
|
-
x: clientX - left,
|
|
367
|
-
y: clientY - top
|
|
368
|
-
};
|
|
369
|
-
if (rows.length < 2) {
|
|
370
|
-
nativeSetDragImage?.(element, offset.x, offset.y);
|
|
646
|
+
selected
|
|
647
|
+
]);
|
|
648
|
+
const [snapshot] = useObject(task);
|
|
649
|
+
const current = snapshot ?? task;
|
|
650
|
+
const draftDescription = useRef("");
|
|
651
|
+
const [draft, setDraft] = useState("");
|
|
652
|
+
const [draftPriority, setDraftPriority] = useState();
|
|
653
|
+
const [draftEstimate, setDraftEstimate] = useState();
|
|
654
|
+
const [files, setFiles] = useState([]);
|
|
655
|
+
const [dragOver, setDragOver] = useState(false);
|
|
656
|
+
const dragDepth = useRef(0);
|
|
657
|
+
const [createEpoch, setCreateEpoch] = useState(0);
|
|
658
|
+
const creating = useRef(false);
|
|
659
|
+
const [editingId, setEditingId] = useState(void 0);
|
|
660
|
+
if (editingId !== current?.id) {
|
|
661
|
+
setEditingId(current?.id);
|
|
662
|
+
setDraft(current?.title ?? "");
|
|
663
|
+
draftDescription.current = "";
|
|
664
|
+
}
|
|
665
|
+
const commitTitle = useCallback(() => {
|
|
666
|
+
const title = draft.trim();
|
|
667
|
+
if (task && current) {
|
|
668
|
+
if (title.length > 0 && title !== current.title) onTaskUpdate?.(task, { title });
|
|
669
|
+
} else if (title.length > 0 && !creating.current) {
|
|
670
|
+
descriptionRef.current?.commit();
|
|
671
|
+
const sentDescription = draftDescription.current;
|
|
672
|
+
const description = sentDescription.trim();
|
|
673
|
+
const sentTitle = draft;
|
|
674
|
+
const sent = files;
|
|
675
|
+
const settle = (result) => {
|
|
676
|
+
creating.current = false;
|
|
677
|
+
if (result?.error) {
|
|
678
|
+
log.warn("task create failed", { error: result.error }, {
|
|
679
|
+
"~LogMeta": "~LogMeta",
|
|
680
|
+
F: __dxlog_file,
|
|
681
|
+
L: 164,
|
|
682
|
+
S: void 0
|
|
683
|
+
});
|
|
371
684
|
return;
|
|
372
685
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
686
|
+
const kept = new Set(result?.rejectedFiles ?? []);
|
|
687
|
+
setFiles((files) => files.filter((file) => !sent.includes(file) || kept.has(file)));
|
|
688
|
+
setDraft((draft) => draft === sentTitle ? "" : draft);
|
|
689
|
+
setDraftPriority(void 0);
|
|
690
|
+
setDraftEstimate(void 0);
|
|
691
|
+
if (draftDescription.current === sentDescription) {
|
|
692
|
+
draftDescription.current = "";
|
|
693
|
+
setCreateEpoch((epoch) => epoch + 1);
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
let result;
|
|
697
|
+
creating.current = true;
|
|
698
|
+
try {
|
|
699
|
+
result = onTaskCreate?.({
|
|
700
|
+
title,
|
|
701
|
+
...description.length > 0 && { description },
|
|
702
|
+
...draftPriority && { priority: draftPriority },
|
|
703
|
+
...draftEstimate && { estimate: draftEstimate }
|
|
704
|
+
}, sent.length > 0 ? sent : void 0);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
creating.current = false;
|
|
707
|
+
log.catch(error, void 0, {
|
|
708
|
+
"~LogMeta": "~LogMeta",
|
|
709
|
+
F: __dxlog_file,
|
|
710
|
+
L: 192,
|
|
711
|
+
S: void 0
|
|
377
712
|
});
|
|
378
|
-
|
|
379
|
-
onDragStart: () => {
|
|
380
|
-
draggingId.current = task.id;
|
|
381
|
-
liftFrame.current = requestAnimationFrame(() => latest.current.onDraggingChange(task));
|
|
382
|
-
},
|
|
383
|
-
onDrop: () => {
|
|
384
|
-
draggingId.current = void 0;
|
|
385
|
-
cancelLift();
|
|
386
|
-
latest.current.onDraggingChange(void 0);
|
|
713
|
+
return;
|
|
387
714
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
mode: itemMode({
|
|
396
|
-
branch: latest.current.row?.branch ?? false,
|
|
397
|
-
open: !latest.current.isCollapsed(task.id),
|
|
398
|
-
last: (latest.current.row?.position ?? 1) === (latest.current.row?.setSize ?? 1)
|
|
399
|
-
}),
|
|
400
|
-
block: []
|
|
401
|
-
}),
|
|
402
|
-
canDrop: ({ source }) => isTaskDragData(source.data) && source.data.taskId !== task.id,
|
|
403
|
-
getIsSticky: () => true,
|
|
404
|
-
onDrag: ({ self }) => {
|
|
405
|
-
const desired = extractInstruction(self.data);
|
|
406
|
-
const next = desired !== null && desired.type !== "instruction-blocked" && !placementFor(desired) ? {
|
|
407
|
-
type: "instruction-blocked",
|
|
408
|
-
desired
|
|
409
|
-
} : desired;
|
|
410
|
-
setInstruction(next);
|
|
411
|
-
const { row, isCollapsed, onCollapseToggle } = latest.current;
|
|
412
|
-
if (next?.type === "make-child" && row?.branch && isCollapsed(task.id) && !expandTimeout.current) expandTimeout.current = setTimeout(() => {
|
|
413
|
-
expandTimeout.current = null;
|
|
414
|
-
onCollapseToggle(task.id);
|
|
415
|
-
}, EXPAND_DWELL);
|
|
416
|
-
else if (next?.type !== "make-child") cancelExpand();
|
|
417
|
-
},
|
|
418
|
-
onDragLeave: () => {
|
|
419
|
-
cancelExpand();
|
|
420
|
-
setInstruction(null);
|
|
421
|
-
},
|
|
422
|
-
onDrop: ({ self, source }) => {
|
|
423
|
-
cancelExpand();
|
|
424
|
-
setInstruction(null);
|
|
425
|
-
if (!isTaskDragData(source.data)) return;
|
|
426
|
-
const moved = latest.current.tasks.find(({ id }) => id === source.data.taskId);
|
|
427
|
-
const intent = dropIntent(extractInstruction(self.data));
|
|
428
|
-
if (!moved || !intent) return;
|
|
429
|
-
const placement = resolveTaskPlacement({
|
|
430
|
-
tasks: latest.current.tasks,
|
|
431
|
-
source: moved,
|
|
432
|
-
target: task,
|
|
433
|
-
intent
|
|
715
|
+
Promise.resolve(result).then(settle, (error) => {
|
|
716
|
+
creating.current = false;
|
|
717
|
+
log.catch(error, void 0, {
|
|
718
|
+
"~LogMeta": "~LogMeta",
|
|
719
|
+
F: __dxlog_file,
|
|
720
|
+
L: 197,
|
|
721
|
+
S: void 0
|
|
434
722
|
});
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}, [
|
|
726
|
+
draft,
|
|
727
|
+
draftPriority,
|
|
728
|
+
draftEstimate,
|
|
729
|
+
files,
|
|
730
|
+
task,
|
|
731
|
+
current,
|
|
732
|
+
onTaskCreate,
|
|
733
|
+
onTaskUpdate
|
|
734
|
+
]);
|
|
735
|
+
const handleTitleBlur = useCallback(() => {
|
|
736
|
+
if (task && current) commitTitle();
|
|
737
|
+
}, [
|
|
738
|
+
task,
|
|
739
|
+
current,
|
|
740
|
+
commitTitle
|
|
741
|
+
]);
|
|
742
|
+
const handleTitleKeyDown = useCallback((event) => {
|
|
743
|
+
if (event.key === "Enter") commitTitle();
|
|
744
|
+
}, [commitTitle]);
|
|
745
|
+
const handleSave = useCallback(() => {
|
|
746
|
+
commitTitle();
|
|
747
|
+
if (task && current) descriptionRef.current?.commit();
|
|
748
|
+
onTaskSelect?.(void 0);
|
|
749
|
+
}, [
|
|
750
|
+
commitTitle,
|
|
751
|
+
task,
|
|
752
|
+
current,
|
|
753
|
+
onTaskSelect
|
|
754
|
+
]);
|
|
755
|
+
const handleSubmit = useCallback(() => {
|
|
756
|
+
if (!(task && current) && draft.trim().length === 0) return;
|
|
757
|
+
handleSave();
|
|
758
|
+
}, [
|
|
759
|
+
task,
|
|
760
|
+
current,
|
|
761
|
+
draft,
|
|
762
|
+
handleSave
|
|
763
|
+
]);
|
|
764
|
+
const submitRef = Hooks.useDynamicRef(handleSubmit);
|
|
765
|
+
const extensions = useMemo(() => [...descriptionExtensions ?? [], submitOnModEnter({ onSubmit: () => submitRef.current() })], [descriptionExtensions]);
|
|
766
|
+
const handleCancel = useCallback(() => {
|
|
767
|
+
if (task && current) descriptionRef.current?.revert();
|
|
768
|
+
else {
|
|
769
|
+
draftDescription.current = "";
|
|
770
|
+
setCreateEpoch((epoch) => epoch + 1);
|
|
771
|
+
}
|
|
772
|
+
setDraft("");
|
|
773
|
+
setFiles([]);
|
|
774
|
+
onTaskSelect?.(void 0);
|
|
438
775
|
}, [
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
cancelLift
|
|
776
|
+
task,
|
|
777
|
+
current,
|
|
778
|
+
onTaskSelect
|
|
443
779
|
]);
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
780
|
+
const takesFiles = acceptFiles && !!onTaskCreate && !(task && current);
|
|
781
|
+
const handleFiles = useCallback((added) => setFiles((files) => [...files, ...added]), []);
|
|
782
|
+
const handleDragEnter = useCallback((event) => {
|
|
783
|
+
if (isFileDrag(event)) {
|
|
784
|
+
dragDepth.current++;
|
|
785
|
+
setDragOver(true);
|
|
786
|
+
}
|
|
787
|
+
}, []);
|
|
788
|
+
const handleDragLeave = useCallback((event) => {
|
|
789
|
+
if (isFileDrag(event) && --dragDepth.current <= 0) {
|
|
790
|
+
dragDepth.current = 0;
|
|
791
|
+
setDragOver(false);
|
|
792
|
+
}
|
|
793
|
+
}, []);
|
|
794
|
+
const handleDragOver = useCallback((event) => {
|
|
795
|
+
if (isFileDrag(event)) {
|
|
796
|
+
event.preventDefault();
|
|
797
|
+
event.stopPropagation();
|
|
798
|
+
event.dataTransfer.dropEffect = "copy";
|
|
799
|
+
}
|
|
800
|
+
}, []);
|
|
801
|
+
const handleDrop = useCallback((event) => {
|
|
802
|
+
dragDepth.current = 0;
|
|
803
|
+
setDragOver(false);
|
|
804
|
+
const dropped = Array.from(event.dataTransfer.files);
|
|
805
|
+
if (dropped.length > 0) {
|
|
806
|
+
event.preventDefault();
|
|
807
|
+
event.stopPropagation();
|
|
808
|
+
handleFiles(dropped);
|
|
809
|
+
}
|
|
810
|
+
}, [handleFiles]);
|
|
811
|
+
const handlePaste = useCallback((event) => {
|
|
812
|
+
const pasted = Array.from(event.clipboardData.files);
|
|
813
|
+
if (pasted.length > 0 && !event.clipboardData.types.includes("text/plain")) {
|
|
814
|
+
event.preventDefault();
|
|
815
|
+
event.stopPropagation();
|
|
816
|
+
handleFiles(pasted);
|
|
817
|
+
}
|
|
818
|
+
}, [handleFiles]);
|
|
819
|
+
if (!onTaskCreate && !(current && onTaskUpdate)) return null;
|
|
820
|
+
const hasDescription = !!(showDescription && (current ? onTaskUpdate : onTaskCreate));
|
|
821
|
+
const canSave = draft.trim().length > 0;
|
|
822
|
+
const canCancel = !!current || canSave;
|
|
823
|
+
const editing = !!(task && current);
|
|
824
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
825
|
+
...rest,
|
|
826
|
+
className: mx("grid w-full min-w-0 shrink-0", (hasDescription || takesFiles && files.length > 0) && "gap-y-2", dragOver && "ring-2 ring-inset ring-accent-bg", !flush && "px-(--dx-gap-size)", className),
|
|
827
|
+
style: {
|
|
828
|
+
"gridTemplateColumns": columns,
|
|
829
|
+
"--dx-col": "auto"
|
|
830
|
+
},
|
|
831
|
+
"data-testid": "taskList.edit",
|
|
832
|
+
...takesFiles && {
|
|
833
|
+
onDragEnterCapture: handleDragEnter,
|
|
834
|
+
onDragLeaveCapture: handleDragLeave,
|
|
835
|
+
onDragOverCapture: handleDragOver,
|
|
836
|
+
onDropCapture: handleDrop,
|
|
837
|
+
onPasteCapture: handlePaste
|
|
838
|
+
},
|
|
839
|
+
ref: forwardedRef,
|
|
840
|
+
children: [
|
|
841
|
+
editing ? /* @__PURE__ */ jsx(TaskStatusControl, {
|
|
842
|
+
task,
|
|
843
|
+
classNames: mx(TRACK.status, "self-start"),
|
|
844
|
+
onTaskUpdate
|
|
845
|
+
}) : /* @__PURE__ */ jsx("span", {
|
|
846
|
+
className: mx(TRACK.status, "flex items-center justify-center h-(--dx-control) self-start"),
|
|
847
|
+
children: /* @__PURE__ */ jsx(Icon.Icon, {
|
|
848
|
+
icon: "ph--plus--regular",
|
|
849
|
+
tone: "subtle"
|
|
850
|
+
})
|
|
851
|
+
}),
|
|
852
|
+
/* @__PURE__ */ jsx(Field.Root, {
|
|
853
|
+
classNames: "row-start-1 col-start-[title] col-end-[assignee] min-w-0",
|
|
854
|
+
children: /* @__PURE__ */ jsx(Input.Root, {
|
|
855
|
+
classNames: "grow text-ellipsis",
|
|
856
|
+
"data-testid": "taskList.edit.title",
|
|
857
|
+
placeholder: current ? t("task-title.placeholder") : placeholder ?? t("add-task.placeholder"),
|
|
858
|
+
value: draft,
|
|
859
|
+
onChange: (event) => setDraft(event.target.value),
|
|
860
|
+
onKeyDown: handleTitleKeyDown,
|
|
861
|
+
onBlur: handleTitleBlur,
|
|
862
|
+
end: canSave && /* @__PURE__ */ jsx(SystemButton.Save, {
|
|
863
|
+
variant: "primary",
|
|
864
|
+
"data-testid": "taskList.edit.save",
|
|
865
|
+
tabIndex: -1,
|
|
866
|
+
onClick: handleSave,
|
|
867
|
+
onMouseDown: (event) => event.preventDefault()
|
|
868
|
+
})
|
|
869
|
+
})
|
|
870
|
+
}),
|
|
871
|
+
hasDescription && /* @__PURE__ */ jsx(Input.Frame, {
|
|
872
|
+
rows: 2,
|
|
873
|
+
"data-testid": "taskList.edit.description",
|
|
874
|
+
classNames: "row-start-2 col-[title/assignee] min-w-0",
|
|
875
|
+
children: /* @__PURE__ */ jsx(MarkdownEditable, {
|
|
876
|
+
ref: descriptionRef,
|
|
877
|
+
classNames: "[&_.cm-scroller]:!leading-normal [&_.cm-scroller]:max-h-[8lh] [&_.cm-scroller]:overflow-y-auto",
|
|
878
|
+
editing: true,
|
|
879
|
+
multiline: true,
|
|
880
|
+
placeholder: descriptionPlaceholder ?? t("task-description.placeholder"),
|
|
881
|
+
extensions,
|
|
882
|
+
autoFocus: false,
|
|
883
|
+
...current && { value: current.description ?? "" },
|
|
884
|
+
onValueChange: (description) => {
|
|
885
|
+
if (task && current) onTaskUpdate?.(task, { description });
|
|
886
|
+
else draftDescription.current = description;
|
|
887
|
+
}
|
|
888
|
+
}, current?.id ?? `create-${createEpoch}`)
|
|
889
|
+
}),
|
|
890
|
+
showEstimates && (editing ? /* @__PURE__ */ jsx(TaskEstimateControl, {
|
|
891
|
+
task,
|
|
892
|
+
classNames: TRACK.estimate
|
|
893
|
+
}) : /* @__PURE__ */ jsx(TaskEstimatePicker, {
|
|
894
|
+
estimate: draftEstimate,
|
|
895
|
+
onChange: setDraftEstimate,
|
|
896
|
+
testId: "taskList.edit.estimate",
|
|
897
|
+
classNames: TRACK.estimate
|
|
898
|
+
})),
|
|
899
|
+
editing ? /* @__PURE__ */ jsx(TaskPriorityIcon, {
|
|
900
|
+
task,
|
|
901
|
+
classNames: TRACK.priority
|
|
902
|
+
}) : /* @__PURE__ */ jsx(TaskPriorityPicker, {
|
|
903
|
+
priority: draftPriority,
|
|
904
|
+
onChange: setDraftPriority,
|
|
905
|
+
testId: "taskList.edit.priority",
|
|
906
|
+
classNames: TRACK.priority
|
|
907
|
+
}),
|
|
908
|
+
canCancel && /* @__PURE__ */ jsx(SystemButton.Cancel, {
|
|
909
|
+
variant: "ghost",
|
|
910
|
+
classNames: TRACK.actions,
|
|
911
|
+
"data-testid": "taskList.edit.cancel",
|
|
912
|
+
onClick: handleCancel,
|
|
913
|
+
onMouseDown: (event) => event.preventDefault()
|
|
914
|
+
}),
|
|
915
|
+
takesFiles && files.length > 0 && /* @__PURE__ */ jsx("div", {
|
|
916
|
+
className: mx("col-start-[title] -col-end-1 flex flex-wrap items-center gap-1 min-w-0", hasDescription ? "row-start-3" : "row-start-2"),
|
|
917
|
+
children: files.map((file, index) => /* @__PURE__ */ jsxs(Tag$1.Tag, {
|
|
918
|
+
hue: "neutral",
|
|
919
|
+
classNames: "inline-flex items-center gap-1",
|
|
920
|
+
"data-testid": "taskList.edit.file",
|
|
921
|
+
children: [
|
|
922
|
+
/* @__PURE__ */ jsx(Icon.Icon, {
|
|
923
|
+
icon: "ph--paperclip--regular",
|
|
924
|
+
size: "xs"
|
|
925
|
+
}),
|
|
926
|
+
/* @__PURE__ */ jsx("span", {
|
|
927
|
+
"data-testid": "taskList.edit.file.name",
|
|
928
|
+
children: file.name
|
|
929
|
+
}),
|
|
930
|
+
/* @__PURE__ */ jsx(Button.Root, {
|
|
931
|
+
variant: "ghost",
|
|
932
|
+
size: "sm",
|
|
933
|
+
iconOnly: true,
|
|
934
|
+
icon: "ph--x--regular",
|
|
935
|
+
iconSize: "xs",
|
|
936
|
+
label: t("remove-file.label", { name: file.name }),
|
|
937
|
+
classNames: "p-0 min-h-0 h-auto",
|
|
938
|
+
onClick: () => setFiles((files) => files.filter((_, position) => position !== index))
|
|
939
|
+
})
|
|
940
|
+
]
|
|
941
|
+
}, `${file.name}-${index}`))
|
|
942
|
+
})
|
|
943
|
+
]
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
/** Whether a drag carries files from outside the page, rather than an element dragged within it. */
|
|
947
|
+
var isFileDrag = (event) => Array.from(event.dataTransfer.types).includes("Files");
|
|
948
|
+
TaskListEditor.displayName = "TaskList.Editor";
|
|
949
|
+
//#endregion
|
|
950
|
+
//#region src/components/TaskList/tree-model.ts
|
|
951
|
+
/** Prefix so a host group's node id cannot collide with a task's, nor with a status group's. */
|
|
952
|
+
var TASK_GROUP_ID_PREFIX = "group:";
|
|
953
|
+
/** The tree node id of a host group, which is also its key in the list's collapsed set. */
|
|
954
|
+
var taskGroupNodeId = (group) => `${TASK_GROUP_ID_PREFIX}${group.id}`;
|
|
955
|
+
/**
|
|
956
|
+
* One collapsible header per non-empty group, its tasks beneath. Nested within a group when
|
|
957
|
+
* `hierarchical`: a sub-task whose parent landed in another group is a root of its own group, since
|
|
958
|
+
* the forest is built from the group's tasks alone.
|
|
959
|
+
*/
|
|
960
|
+
var buildTaskGroups = (groups, hierarchical = true) => ({
|
|
961
|
+
id: TASK_TREE_ROOT_ID,
|
|
962
|
+
children: groups.filter((group) => group.tasks.length > 0).map(({ tasks, ...header }) => ({
|
|
963
|
+
id: taskGroupNodeId(header),
|
|
964
|
+
group: {
|
|
965
|
+
...header,
|
|
966
|
+
count: tasks.length
|
|
967
|
+
},
|
|
968
|
+
children: (hierarchical ? buildTaskForest(tasks) : buildFlatList(tasks)).children
|
|
969
|
+
}))
|
|
970
|
+
});
|
|
971
|
+
/** Synthetic root; the tree renders its children. */
|
|
972
|
+
var TASK_TREE_ROOT_ID = "tasks";
|
|
973
|
+
/**
|
|
974
|
+
* Builds the task forest. Cycle-safe: a malformed parent loop is visited once and then
|
|
975
|
+
* skipped, so a corrupt set renders short rather than hanging.
|
|
976
|
+
*/
|
|
977
|
+
var buildTaskForest = (tasks) => {
|
|
978
|
+
const childrenOf = Task.childIndex(tasks);
|
|
979
|
+
const seen = /* @__PURE__ */ new Set();
|
|
980
|
+
const visit = (task) => {
|
|
981
|
+
seen.add(task.id);
|
|
982
|
+
const children = childrenOf(task).filter((child) => !seen.has(child.id));
|
|
983
|
+
return {
|
|
984
|
+
id: task.id,
|
|
985
|
+
task,
|
|
986
|
+
children: children.map(visit)
|
|
987
|
+
};
|
|
988
|
+
};
|
|
448
989
|
return {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
dragHandleRef,
|
|
452
|
-
dragHandle: enabled
|
|
990
|
+
id: TASK_TREE_ROOT_ID,
|
|
991
|
+
children: Task.rootTasks(tasks).filter((task) => !seen.has(task.id)).map(visit)
|
|
453
992
|
};
|
|
454
993
|
};
|
|
455
994
|
/**
|
|
456
|
-
*
|
|
457
|
-
*
|
|
995
|
+
* Path to each task, keyed by id and including the synthetic root — what `Tree` addresses rows by.
|
|
996
|
+
* Unambiguous because a task has exactly one parent, so it appears at exactly one path.
|
|
458
997
|
*/
|
|
459
|
-
var
|
|
460
|
-
const
|
|
461
|
-
const
|
|
462
|
-
|
|
998
|
+
var buildTaskPaths = (root) => {
|
|
999
|
+
const paths = /* @__PURE__ */ new Map();
|
|
1000
|
+
const visit = (node, path) => {
|
|
1001
|
+
const next = [...path, node.id];
|
|
1002
|
+
if (node.task) paths.set(node.id, next);
|
|
1003
|
+
node.children.forEach((child) => visit(child, next));
|
|
1004
|
+
};
|
|
1005
|
+
visit(root, []);
|
|
1006
|
+
return paths;
|
|
1007
|
+
};
|
|
1008
|
+
/** Every task as a root: the flat list, expressed as a tree of depth one. */
|
|
1009
|
+
var buildFlatList = (tasks) => ({
|
|
1010
|
+
id: TASK_TREE_ROOT_ID,
|
|
1011
|
+
children: tasks.map((task) => ({
|
|
1012
|
+
id: task.id,
|
|
1013
|
+
task,
|
|
1014
|
+
children: []
|
|
1015
|
+
}))
|
|
1016
|
+
});
|
|
1017
|
+
/** Prefix so a group's node id cannot collide with a task's. */
|
|
1018
|
+
var GROUP_ID_PREFIX = "status:";
|
|
1019
|
+
/** Builds the status-grouped forest: one group node per non-empty status, tasks flat within it. */
|
|
1020
|
+
var buildStatusGroups = (tasks, order) => ({
|
|
1021
|
+
id: TASK_TREE_ROOT_ID,
|
|
1022
|
+
children: order.map((status) => ({
|
|
1023
|
+
id: `${GROUP_ID_PREFIX}${status}`,
|
|
1024
|
+
status,
|
|
1025
|
+
children: tasks.filter((task) => (task.status ?? "todo") === status).map((task) => ({
|
|
1026
|
+
id: task.id,
|
|
1027
|
+
task,
|
|
1028
|
+
children: []
|
|
1029
|
+
}))
|
|
1030
|
+
})).filter((group) => group.children.length > 0)
|
|
1031
|
+
});
|
|
1032
|
+
/** The forest a list renders under `options` — shared by the model and the paths into it. */
|
|
1033
|
+
var buildTaskTree = (tasks, { groups, groupByStatus, hierarchical = true } = {}) => groups ? buildTaskGroups(groups, hierarchical) : groupByStatus ? buildStatusGroups(tasks, groupByStatus) : hierarchical ? buildTaskForest(tasks) : buildFlatList(tasks);
|
|
1034
|
+
/** A task's title, a group's label or translated status, or the synthetic root's id. */
|
|
1035
|
+
var groupLabel = (node, translationKey) => {
|
|
1036
|
+
if (node.task) return node.task.title ?? node.id;
|
|
1037
|
+
if (node.group) return node.group.label;
|
|
1038
|
+
if (node.status) return translationKey ? [`status-${node.status}.label`, { ns: translationKey }] : node.status;
|
|
1039
|
+
return node.id;
|
|
463
1040
|
};
|
|
464
1041
|
/**
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
1042
|
+
* A {@link StaticTreeModel} over the task forest.
|
|
1043
|
+
*
|
|
1044
|
+
* The model is rebuilt whenever the task array changes, so open state is seeded from `collapsed`
|
|
1045
|
+
* rather than held inside it — a collapse kept only in the model would be dropped by the next
|
|
1046
|
+
* rebuild, and tasks are live ECHO objects.
|
|
468
1047
|
*/
|
|
469
|
-
var
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
for (const row of rows) container.appendChild(row.cloneNode(true));
|
|
482
|
-
};
|
|
1048
|
+
var createTaskTreeModel = (tasks, { collapsed, groups, groupByStatus, translationKey, hierarchical = true } = {}) => createStaticTreeModel(buildTaskTree(tasks, {
|
|
1049
|
+
groups,
|
|
1050
|
+
groupByStatus,
|
|
1051
|
+
hierarchical
|
|
1052
|
+
}), {
|
|
1053
|
+
getChildren: (node) => node.children,
|
|
1054
|
+
getProps: (node) => ({
|
|
1055
|
+
label: groupLabel(node, translationKey),
|
|
1056
|
+
...node.status ? { disposition: "group" } : node.group ? { testId: "taskList.group" } : { testId: "taskList.item" }
|
|
1057
|
+
}),
|
|
1058
|
+
isOpen: (node) => !collapsed?.has(node.id)
|
|
1059
|
+
});
|
|
483
1060
|
/**
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
1061
|
+
* Tasks in the order the tree shows them, a collapsed branch contributing only its own row.
|
|
1062
|
+
*
|
|
1063
|
+
* Derived from the forest the tree renders rather than from a second walk, so the ordinals a reader
|
|
1064
|
+
* counts down cannot drift from the rows they number.
|
|
487
1065
|
*/
|
|
488
|
-
var
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
classNames: "text-info-text"
|
|
499
|
-
},
|
|
500
|
-
done: {
|
|
501
|
-
icon: "ph--check--regular",
|
|
502
|
-
classNames: "text-success-text"
|
|
503
|
-
},
|
|
504
|
-
failed: {
|
|
505
|
-
icon: "ph--x--regular",
|
|
506
|
-
classNames: "text-error-text"
|
|
507
|
-
},
|
|
508
|
-
cancelled: {
|
|
509
|
-
icon: "ph--x--regular",
|
|
510
|
-
classNames: "text-error-text"
|
|
511
|
-
}
|
|
1066
|
+
var flattenVisibleTasks = (root, collapsed) => {
|
|
1067
|
+
const tasks = [];
|
|
1068
|
+
const visit = (nodes) => {
|
|
1069
|
+
for (const node of nodes) {
|
|
1070
|
+
if (node.task) tasks.push(node.task);
|
|
1071
|
+
if (!collapsed?.has(node.id)) visit(node.children);
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
visit(root.children);
|
|
1075
|
+
return tasks;
|
|
512
1076
|
};
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const {
|
|
517
|
-
const {
|
|
518
|
-
|
|
519
|
-
|
|
1077
|
+
//#endregion
|
|
1078
|
+
//#region src/components/TaskList/TaskTreeNode.tsx
|
|
1079
|
+
var TaskTreeNode = ({ groupByStatus, groups, hierarchical, tasks, collapsed, toggle, ordinals, selected, checked, columns, renderItem: Item, translationKey, showDescription = false, descriptionComponents, onCollapseToggle, onTaskCheck, onTaskSelect, onTaskUpdate, onTaskMove }) => {
|
|
1080
|
+
const { flush } = useTaskListContext("TaskList.TreeNode");
|
|
1081
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
1082
|
+
const registry = useContext(RegistryContext);
|
|
1083
|
+
const collapsedRef = useRef(collapsed);
|
|
1084
|
+
collapsedRef.current = collapsed;
|
|
1085
|
+
const model = useMemo(() => createTaskTreeModel(tasks, {
|
|
1086
|
+
collapsed: collapsedRef.current,
|
|
1087
|
+
groups,
|
|
1088
|
+
groupByStatus,
|
|
1089
|
+
translationKey,
|
|
1090
|
+
hierarchical
|
|
1091
|
+
}), [
|
|
520
1092
|
tasks,
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
1093
|
+
groups,
|
|
1094
|
+
groupByStatus,
|
|
1095
|
+
translationKey,
|
|
1096
|
+
hierarchical
|
|
1097
|
+
]);
|
|
1098
|
+
const paths = useMemo(() => buildTaskPaths(buildTaskTree(tasks, {
|
|
1099
|
+
groups,
|
|
1100
|
+
groupByStatus,
|
|
1101
|
+
hierarchical
|
|
1102
|
+
})), [
|
|
1103
|
+
tasks,
|
|
1104
|
+
groups,
|
|
1105
|
+
groupByStatus,
|
|
1106
|
+
hierarchical
|
|
1107
|
+
]);
|
|
1108
|
+
const previousSelected = useRef(void 0);
|
|
1109
|
+
useEffect(() => {
|
|
1110
|
+
const setCurrent = (id, current) => {
|
|
1111
|
+
const path = id && paths.get(id);
|
|
1112
|
+
if (!path) return;
|
|
1113
|
+
const atom = model.stateAtom(path);
|
|
1114
|
+
registry.set(atom, {
|
|
1115
|
+
...registry.get(atom),
|
|
1116
|
+
current
|
|
1117
|
+
});
|
|
1118
|
+
};
|
|
1119
|
+
if (previousSelected.current !== selected) {
|
|
1120
|
+
setCurrent(previousSelected.current, false);
|
|
1121
|
+
previousSelected.current = selected;
|
|
1122
|
+
}
|
|
1123
|
+
setCurrent(selected, true);
|
|
1124
|
+
}, [
|
|
1125
|
+
selected,
|
|
1126
|
+
model,
|
|
1127
|
+
paths,
|
|
1128
|
+
registry
|
|
1129
|
+
]);
|
|
1130
|
+
const handleOpenChange = useCallback(({ item, path, open }) => {
|
|
1131
|
+
const atom = model.stateAtom(path);
|
|
1132
|
+
registry.set(atom, {
|
|
1133
|
+
...registry.get(atom),
|
|
1134
|
+
open
|
|
1135
|
+
});
|
|
1136
|
+
if (open === collapsedRef.current.has(item.id)) onCollapseToggle(item.id);
|
|
1137
|
+
}, [
|
|
1138
|
+
model,
|
|
1139
|
+
registry,
|
|
1140
|
+
onCollapseToggle
|
|
541
1141
|
]);
|
|
1142
|
+
const canSelect = useCallback(({ item }) => !!onTaskSelect && !!item.task, [onTaskSelect]);
|
|
1143
|
+
const handleSelect = useCallback(({ item, meta }) => item.task && onTaskSelect?.(item.task, { meta }), [onTaskSelect]);
|
|
542
1144
|
const handleKeyDown = useCallback((event) => {
|
|
543
|
-
if (
|
|
1145
|
+
if (event.key === "Escape" && selected) {
|
|
1146
|
+
event.preventDefault();
|
|
1147
|
+
onTaskSelect?.(void 0);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const tab = event.key === "Tab";
|
|
1151
|
+
if (!onTaskMove || !(event.shiftKey || tab)) return;
|
|
1152
|
+
const target = event.target instanceof HTMLElement ? event.target : void 0;
|
|
1153
|
+
const row = target?.closest("[data-object-id]");
|
|
1154
|
+
if (!row || tab && row !== target) return;
|
|
1155
|
+
const id = row.getAttribute("data-object-id");
|
|
1156
|
+
const task = id ? tasks.find((task) => task.id === id) : void 0;
|
|
1157
|
+
if (!task) return;
|
|
544
1158
|
const placement = (() => {
|
|
545
1159
|
switch (event.key) {
|
|
1160
|
+
case "Tab": return event.shiftKey ? resolveOutdent(tasks, task) : resolveIndent(tasks, task);
|
|
546
1161
|
case "ArrowRight": return resolveIndent(tasks, task);
|
|
547
1162
|
case "ArrowLeft": return resolveOutdent(tasks, task);
|
|
548
1163
|
case "ArrowUp": return resolveNudge(tasks, task, "up");
|
|
@@ -556,185 +1171,1022 @@ var TaskListItem = composable(({ task, ordinal, row, ...props }, forwardedRef) =
|
|
|
556
1171
|
onTaskMove(task, placement);
|
|
557
1172
|
}
|
|
558
1173
|
}, [
|
|
559
|
-
onTaskMove,
|
|
560
|
-
row,
|
|
561
1174
|
tasks,
|
|
562
|
-
|
|
1175
|
+
selected,
|
|
1176
|
+
onTaskMove,
|
|
1177
|
+
onTaskSelect
|
|
563
1178
|
]);
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
1179
|
+
const handleDrop = useCallback(({ instruction, source, item, atEnd }) => {
|
|
1180
|
+
const sourceTask = tasks.find((task) => task.id === source.id);
|
|
1181
|
+
if (!onTaskMove || !sourceTask) return;
|
|
1182
|
+
if (atEnd) {
|
|
1183
|
+
onTaskMove(sourceTask, {
|
|
1184
|
+
parentTask: null,
|
|
1185
|
+
before: void 0
|
|
1186
|
+
});
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const targetTask = item.task;
|
|
1190
|
+
if (!targetTask || instruction.type === "instruction-blocked") return;
|
|
1191
|
+
const placement = instruction.type === "reparent" ? resolveReparent(tasks, sourceTask, targetTask, instruction.currentLevel - instruction.desiredLevel) : resolveTaskPlacement({
|
|
1192
|
+
tasks,
|
|
1193
|
+
source: sourceTask,
|
|
1194
|
+
target: targetTask,
|
|
1195
|
+
intent: instruction.type
|
|
1196
|
+
});
|
|
1197
|
+
if (placement) onTaskMove(sourceTask, placement);
|
|
1198
|
+
}, [tasks, onTaskMove]);
|
|
1199
|
+
const renderRow = useCallback((node) => {
|
|
1200
|
+
const header = node.item?.group;
|
|
1201
|
+
if (node.group || !node.item) return /* @__PURE__ */ jsx(Tree.Item, { node });
|
|
1202
|
+
if (header) return /* @__PURE__ */ jsxs(Tree.Item, {
|
|
1203
|
+
node,
|
|
1204
|
+
children: [/* @__PURE__ */ jsx(Tree.ItemIndicator, {}), /* @__PURE__ */ jsx(TaskGroupHeading, {
|
|
1205
|
+
group: header,
|
|
1206
|
+
translationKey
|
|
1207
|
+
})]
|
|
1208
|
+
});
|
|
1209
|
+
const task = node.item.task;
|
|
1210
|
+
return /* @__PURE__ */ jsx(Tree.Item, {
|
|
1211
|
+
node,
|
|
1212
|
+
children: /* @__PURE__ */ jsx(Item, {
|
|
1213
|
+
node: node.item,
|
|
1214
|
+
indicator: toggle ? /* @__PURE__ */ jsx(Tree.ItemIndicator, {}) : void 0,
|
|
1215
|
+
ordinal: task && ordinals.get(task.id),
|
|
1216
|
+
checked: !!task && !!checked?.has(task.id),
|
|
1217
|
+
translationKey,
|
|
1218
|
+
showDescription,
|
|
1219
|
+
descriptionComponents,
|
|
1220
|
+
onTaskCheck,
|
|
1221
|
+
onTaskUpdate
|
|
1222
|
+
})
|
|
1223
|
+
});
|
|
1224
|
+
}, [
|
|
1225
|
+
toggle,
|
|
1226
|
+
ordinals,
|
|
1227
|
+
checked,
|
|
1228
|
+
translationKey,
|
|
1229
|
+
showDescription,
|
|
1230
|
+
descriptionComponents,
|
|
1231
|
+
onTaskCheck,
|
|
1232
|
+
onTaskUpdate,
|
|
1233
|
+
Item
|
|
1234
|
+
]);
|
|
1235
|
+
return /* @__PURE__ */ jsxs(Tree.Root, {
|
|
1236
|
+
id: TASK_TREE_ROOT_ID,
|
|
1237
|
+
model,
|
|
1238
|
+
columns,
|
|
1239
|
+
multiline: true,
|
|
1240
|
+
virtual: "measured",
|
|
1241
|
+
draggable: !!onTaskMove,
|
|
1242
|
+
leavesAcceptChildren: true,
|
|
1243
|
+
dropAtEnd: true,
|
|
1244
|
+
indentGuides: true,
|
|
1245
|
+
canSelect,
|
|
1246
|
+
onOpenChange: handleOpenChange,
|
|
1247
|
+
onSelect: handleSelect,
|
|
578
1248
|
onKeyDown: handleKeyDown,
|
|
579
|
-
|
|
1249
|
+
onDrop: handleDrop,
|
|
1250
|
+
children: [/* @__PURE__ */ jsx(Tree.Label, {
|
|
1251
|
+
srOnly: true,
|
|
1252
|
+
children: t("task-list.label")
|
|
1253
|
+
}), /* @__PURE__ */ jsx(Tree.Content, {
|
|
1254
|
+
rowInset: !flush,
|
|
1255
|
+
children: renderRow
|
|
1256
|
+
})]
|
|
1257
|
+
});
|
|
1258
|
+
};
|
|
1259
|
+
/**
|
|
1260
|
+
* A group's header: its icon, label and how many tasks it holds, spanning the row from after the
|
|
1261
|
+
* disclosure up to the trailing controls. The count is of the group's tasks, sub-tasks included, so
|
|
1262
|
+
* it matches what expanding shows.
|
|
1263
|
+
*/
|
|
1264
|
+
var TaskGroupHeading = ({ group, translationKey }) => {
|
|
1265
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
1266
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1267
|
+
className: "col-[2/assignee] flex min-w-0 items-center gap-2",
|
|
1268
|
+
"data-testid": "taskList.group.header",
|
|
580
1269
|
children: [
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
classNames: mx("tabular-nums", dragHandle && "group-hover/row:invisible group-has-[:focus-visible]/row:invisible"),
|
|
586
|
-
children: ordinal
|
|
587
|
-
}), dragHandle && /* @__PURE__ */ jsx("span", {
|
|
588
|
-
"data-testid": "taskList.dragHandle",
|
|
589
|
-
"aria-hidden": true,
|
|
590
|
-
className: mx("absolute inset-0 grid place-items-center text-subdued cursor-grab active:cursor-grabbing", ordinal !== void 0 && "invisible group-hover/row:visible group-has-[:focus-visible]/row:visible"),
|
|
591
|
-
ref: dragHandleRef,
|
|
592
|
-
children: /* @__PURE__ */ jsx(Icon, {
|
|
593
|
-
icon: "ph--dots-six-vertical--regular",
|
|
594
|
-
size: 4
|
|
595
|
-
})
|
|
596
|
-
})]
|
|
1270
|
+
group.icon && /* @__PURE__ */ jsx(Icon.Icon, {
|
|
1271
|
+
icon: group.icon,
|
|
1272
|
+
size: "md",
|
|
1273
|
+
classNames: group.iconClassNames
|
|
597
1274
|
}),
|
|
598
|
-
|
|
599
|
-
|
|
1275
|
+
/* @__PURE__ */ jsx("span", {
|
|
1276
|
+
className: "truncate font-medium",
|
|
1277
|
+
children: Theme.toLocalizedString(group.label, t)
|
|
1278
|
+
}),
|
|
1279
|
+
/* @__PURE__ */ jsx("span", {
|
|
1280
|
+
className: "text-sm text-fg-muted",
|
|
1281
|
+
"data-testid": "taskList.group.count",
|
|
1282
|
+
children: group.count
|
|
1283
|
+
})
|
|
1284
|
+
]
|
|
1285
|
+
});
|
|
1286
|
+
};
|
|
1287
|
+
//#endregion
|
|
1288
|
+
//#region src/components/TaskList/anchor-hover.ts
|
|
1289
|
+
/** Delay before hover opens the preview — long enough that crossing the anchor en route elsewhere does not fire it. */
|
|
1290
|
+
var HOVER_OPEN_DELAY = 100;
|
|
1291
|
+
/** Grace period after the pointer leaves the anchor/card before the preview closes, so it can travel between them. */
|
|
1292
|
+
var HOVER_CLOSE_DELAY = 300;
|
|
1293
|
+
/**
|
|
1294
|
+
* Hover intent for an element that previews an object in a popover card: opens after a short hover,
|
|
1295
|
+
* closes once the pointer has left both the anchor and the card, and stops closing on leave once the
|
|
1296
|
+
* reader interacts with the card.
|
|
1297
|
+
*/
|
|
1298
|
+
var AnchorHover = class {
|
|
1299
|
+
#anchor;
|
|
1300
|
+
#open;
|
|
1301
|
+
#close;
|
|
1302
|
+
#openTimer;
|
|
1303
|
+
#closeTimer;
|
|
1304
|
+
/** True while a card opened by hover (not click) is showing; only then does leaving dismiss it. */
|
|
1305
|
+
#hoverOpen = false;
|
|
1306
|
+
constructor({ anchor, open, close }) {
|
|
1307
|
+
this.#anchor = anchor;
|
|
1308
|
+
this.#open = open;
|
|
1309
|
+
this.#close = close;
|
|
1310
|
+
}
|
|
1311
|
+
enter(pointerType) {
|
|
1312
|
+
if (pointerType === "touch") return;
|
|
1313
|
+
this.#cancelClose();
|
|
1314
|
+
if (this.#hoverOpen || this.#openTimer !== void 0) return;
|
|
1315
|
+
this.#openTimer = setTimeout(() => {
|
|
1316
|
+
this.#openTimer = void 0;
|
|
1317
|
+
this.#openFromHover();
|
|
1318
|
+
}, HOVER_OPEN_DELAY);
|
|
1319
|
+
}
|
|
1320
|
+
leave() {
|
|
1321
|
+
this.#cancelOpen();
|
|
1322
|
+
if (this.#hoverOpen) this.#scheduleClose();
|
|
1323
|
+
}
|
|
1324
|
+
blur() {
|
|
1325
|
+
if (this.#hoverOpen) this.#scheduleClose();
|
|
1326
|
+
}
|
|
1327
|
+
/** Cancels pending timers and stops tracking the pointer; a card already showing stays open. */
|
|
1328
|
+
reset() {
|
|
1329
|
+
this.#cancelOpen();
|
|
1330
|
+
this.#cancelClose();
|
|
1331
|
+
this.#hoverOpen = false;
|
|
1332
|
+
document.removeEventListener("pointerover", this.#handleDocumentPointerOver);
|
|
1333
|
+
document.removeEventListener("pointerdown", this.#handleDocumentPointerDown, { capture: true });
|
|
1334
|
+
}
|
|
1335
|
+
#openFromHover() {
|
|
1336
|
+
this.#hoverOpen = true;
|
|
1337
|
+
document.addEventListener("pointerover", this.#handleDocumentPointerOver);
|
|
1338
|
+
document.addEventListener("pointerdown", this.#handleDocumentPointerDown, { capture: true });
|
|
1339
|
+
this.#open();
|
|
1340
|
+
}
|
|
1341
|
+
#cancelOpen() {
|
|
1342
|
+
if (this.#openTimer !== void 0) {
|
|
1343
|
+
clearTimeout(this.#openTimer);
|
|
1344
|
+
this.#openTimer = void 0;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
#cancelClose() {
|
|
1348
|
+
if (this.#closeTimer !== void 0) {
|
|
1349
|
+
clearTimeout(this.#closeTimer);
|
|
1350
|
+
this.#closeTimer = void 0;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
#scheduleClose() {
|
|
1354
|
+
if (this.#closeTimer === void 0) this.#closeTimer = setTimeout(() => {
|
|
1355
|
+
this.#closeTimer = void 0;
|
|
1356
|
+
this.reset();
|
|
1357
|
+
this.#close();
|
|
1358
|
+
}, HOVER_CLOSE_DELAY);
|
|
1359
|
+
}
|
|
1360
|
+
#handleDocumentPointerOver = (event) => {
|
|
1361
|
+
const target = event.target;
|
|
1362
|
+
if (target instanceof Element && (this.#anchor.contains(target) || !!target.closest(`[${DX_POPOVER_CONTENT_ATTR}]`))) this.#cancelClose();
|
|
1363
|
+
else this.#scheduleClose();
|
|
1364
|
+
};
|
|
1365
|
+
#handleDocumentPointerDown = (event) => {
|
|
1366
|
+
const target = event.target;
|
|
1367
|
+
if (target instanceof Element && target.closest(`[${DX_POPOVER_CONTENT_ATTR}]`)) this.reset();
|
|
1368
|
+
};
|
|
1369
|
+
};
|
|
1370
|
+
//#endregion
|
|
1371
|
+
//#region src/components/TaskList/usePreviewAnchor.ts
|
|
1372
|
+
/**
|
|
1373
|
+
* Wires a chip to the object it names: hovering shows the object's card, leaving closes it, and a
|
|
1374
|
+
* click (or Enter/Space) opens the object itself. Spread the result onto the chip.
|
|
1375
|
+
*/
|
|
1376
|
+
var usePreviewAnchor = ({ eid, label, title }) => {
|
|
1377
|
+
const elementRef = useRef(null);
|
|
1378
|
+
const hoverRef = useRef(void 0);
|
|
1379
|
+
const dispatch = useCallback((props) => {
|
|
1380
|
+
const trigger = elementRef.current;
|
|
1381
|
+
if (trigger && eid) trigger.dispatchEvent(new DxAnchorActivate({
|
|
1382
|
+
trigger,
|
|
1383
|
+
eid,
|
|
1384
|
+
label,
|
|
1385
|
+
kind: "card",
|
|
1386
|
+
title,
|
|
1387
|
+
...props
|
|
1388
|
+
}));
|
|
1389
|
+
}, [
|
|
1390
|
+
eid,
|
|
1391
|
+
label,
|
|
1392
|
+
title
|
|
1393
|
+
]);
|
|
1394
|
+
const dispatchRef = useRef(dispatch);
|
|
1395
|
+
useEffect(() => {
|
|
1396
|
+
dispatchRef.current = dispatch;
|
|
1397
|
+
}, [dispatch]);
|
|
1398
|
+
const ref = useCallback((element) => {
|
|
1399
|
+
hoverRef.current?.reset();
|
|
1400
|
+
elementRef.current = element;
|
|
1401
|
+
hoverRef.current = element ? new AnchorHover({
|
|
1402
|
+
anchor: element,
|
|
1403
|
+
open: () => dispatchRef.current(),
|
|
1404
|
+
close: () => dispatchRef.current({ state: false })
|
|
1405
|
+
}) : void 0;
|
|
1406
|
+
}, []);
|
|
1407
|
+
const openObject = useCallback(() => {
|
|
1408
|
+
hoverRef.current?.reset();
|
|
1409
|
+
dispatch({ navigate: true });
|
|
1410
|
+
}, [dispatch]);
|
|
1411
|
+
return useMemo(() => ({
|
|
1412
|
+
ref,
|
|
1413
|
+
onPointerEnter: (event) => {
|
|
1414
|
+
if (eid) hoverRef.current?.enter(event.pointerType);
|
|
1415
|
+
},
|
|
1416
|
+
onPointerLeave: () => hoverRef.current?.leave(),
|
|
1417
|
+
onBlur: () => hoverRef.current?.blur(),
|
|
1418
|
+
onClick: (event) => {
|
|
1419
|
+
event.stopPropagation();
|
|
1420
|
+
openObject();
|
|
1421
|
+
},
|
|
1422
|
+
onKeyDown: (event) => {
|
|
1423
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1424
|
+
event.preventDefault();
|
|
1425
|
+
event.stopPropagation();
|
|
1426
|
+
openObject();
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
}), [
|
|
1430
|
+
ref,
|
|
1431
|
+
eid,
|
|
1432
|
+
openObject
|
|
1433
|
+
]);
|
|
1434
|
+
};
|
|
1435
|
+
//#endregion
|
|
1436
|
+
//#region src/components/TaskList/TaskList.tsx
|
|
1437
|
+
/** Shared empty set, so a list with nothing in flight does not allocate one per render. */
|
|
1438
|
+
var EMPTY_IDS = /* @__PURE__ */ new Set();
|
|
1439
|
+
/** Shared empty map, for a tree rendered without the ordinal gutter. */
|
|
1440
|
+
var EMPTY_ORDINALS = /* @__PURE__ */ new Map();
|
|
1441
|
+
var TaskListRoot = ({ children, tasks, groupByStatus = true, groups, showGroupLabels = true, showOrdinals = false, showDescription = false, descriptionComponents, showEstimates = false, showAssignees = true, showMnemonics = true, flush = false, hierarchical = false, collapsed, selected: selectedProp, selectable: selectableProp, checked = EMPTY_IDS, getTaskActions, onTaskCreate, onTaskUpdate, onTaskSelect, onTaskCheck, onTaskMove, onCollapsedChange }) => {
|
|
1442
|
+
const [selectedState, setSelectedState] = useState(selectedProp);
|
|
1443
|
+
const selected = selectedProp ?? selectedState;
|
|
1444
|
+
const selectable = selectableProp ?? (!!onTaskSelect || selectedProp !== void 0);
|
|
1445
|
+
const handleSelect = useCallback((task, modifiers) => {
|
|
1446
|
+
setSelectedState(task?.id);
|
|
1447
|
+
onTaskSelect?.(task, modifiers);
|
|
1448
|
+
}, [onTaskSelect]);
|
|
1449
|
+
const collapsedControlled = collapsed !== void 0 || !!onCollapsedChange;
|
|
1450
|
+
const [collapsedState, setCollapsedState] = useState(EMPTY_IDS);
|
|
1451
|
+
const collapsedIds = collapsedControlled ? collapsed ?? EMPTY_IDS : collapsedState;
|
|
1452
|
+
const collapsedIdsRef = useRef(collapsedIds);
|
|
1453
|
+
collapsedIdsRef.current = collapsedIds;
|
|
1454
|
+
const isCollapsed = useCallback((id) => collapsedIds.has(id), [collapsedIds]);
|
|
1455
|
+
const onCollapseToggle = useCallback((id) => {
|
|
1456
|
+
const next = new Set(collapsedIdsRef.current);
|
|
1457
|
+
if (next.has(id)) next.delete(id);
|
|
1458
|
+
else next.add(id);
|
|
1459
|
+
collapsedIdsRef.current = next;
|
|
1460
|
+
if (!collapsedControlled) setCollapsedState(next);
|
|
1461
|
+
onCollapsedChange?.(next);
|
|
1462
|
+
}, [collapsedControlled, onCollapsedChange]);
|
|
1463
|
+
const showGutter = showOrdinals || showMnemonics || !!onTaskCheck;
|
|
1464
|
+
const hasActions = !!getTaskActions || !!onTaskCreate || !!onTaskUpdate;
|
|
1465
|
+
const columns = useMemo(() => buildGridTemplate({
|
|
1466
|
+
toggle: hierarchical || !!groups,
|
|
1467
|
+
gutter: (onTaskCheck ? 1 : 0) + (showOrdinals || showMnemonics ? 1 : 0),
|
|
1468
|
+
showEstimates,
|
|
1469
|
+
showAssignees,
|
|
1470
|
+
hasActions
|
|
1471
|
+
}), [
|
|
1472
|
+
hierarchical,
|
|
1473
|
+
groups,
|
|
1474
|
+
showOrdinals,
|
|
1475
|
+
onTaskCheck,
|
|
1476
|
+
showMnemonics,
|
|
1477
|
+
showEstimates,
|
|
1478
|
+
showAssignees,
|
|
1479
|
+
hasActions
|
|
1480
|
+
]);
|
|
1481
|
+
return /* @__PURE__ */ jsx(TaskListProvider, {
|
|
1482
|
+
tasks,
|
|
1483
|
+
columns,
|
|
1484
|
+
groupByStatus,
|
|
1485
|
+
groups,
|
|
1486
|
+
showGroupLabels,
|
|
1487
|
+
showOrdinals,
|
|
1488
|
+
showDescription,
|
|
1489
|
+
descriptionComponents,
|
|
1490
|
+
showEstimates,
|
|
1491
|
+
hierarchical,
|
|
1492
|
+
showGutter,
|
|
1493
|
+
showAssignees,
|
|
1494
|
+
showMnemonics,
|
|
1495
|
+
flush,
|
|
1496
|
+
isCollapsed,
|
|
1497
|
+
selected,
|
|
1498
|
+
checked,
|
|
1499
|
+
getTaskActions,
|
|
1500
|
+
onCollapseToggle,
|
|
1501
|
+
onTaskCreate,
|
|
1502
|
+
onTaskUpdate,
|
|
1503
|
+
onTaskSelect: selectable ? handleSelect : void 0,
|
|
1504
|
+
onTaskCheck,
|
|
1505
|
+
onTaskMove,
|
|
1506
|
+
children
|
|
1507
|
+
});
|
|
1508
|
+
};
|
|
1509
|
+
TaskListRoot.displayName = "TaskList.Root";
|
|
1510
|
+
var TaskListViewport = Util.composable(({ children, rows: rowsProp, ...props }, forwardedRef) => {
|
|
1511
|
+
const { className, style, ...rest } = Util.composableProps(props);
|
|
1512
|
+
const rows = rowsProp === void 0 ? void 0 : Math.max(Math.floor(rowsProp), 0);
|
|
1513
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1514
|
+
...rest,
|
|
1515
|
+
className: mx("flex flex-col min-h-0 dx-shrink", className),
|
|
1516
|
+
style: rows === void 0 ? style : {
|
|
1517
|
+
...style,
|
|
1518
|
+
maxHeight: `calc(${rows} * var(--dx-block-size))`
|
|
1519
|
+
},
|
|
1520
|
+
ref: forwardedRef,
|
|
1521
|
+
children
|
|
1522
|
+
});
|
|
1523
|
+
});
|
|
1524
|
+
TaskListViewport.displayName = "TaskList.Viewport";
|
|
1525
|
+
/** Ordinals stop at 99: the gutter is sized for two digits. */
|
|
1526
|
+
var MAX_ORDINAL = 99;
|
|
1527
|
+
/**
|
|
1528
|
+
* One column template per list, built from its options and shared by the tree's rows and the edit pane (the create row
|
|
1529
|
+
* sits outside the scrolling tree, in a grid of its own), so the pane's icon sits under the rows' status controls and
|
|
1530
|
+
* its field starts where their titles do.
|
|
1531
|
+
*
|
|
1532
|
+
* Every cell is placed by its track's name ({@link TRACK}), so a track exists only when its option is on and an absent
|
|
1533
|
+
* cell leaves its track empty. The first track is the tree's disclosure — at no width in a flat list — and goes unnamed:
|
|
1534
|
+
* the row wraps its columns in `[content-start] …`, and two bracketed names with no track between them invalidate the
|
|
1535
|
+
* template. The disclosure is the one cell not placed by name; it flows into that first track.
|
|
1536
|
+
*/
|
|
1537
|
+
var buildGridTemplate = ({ toggle, gutter, showEstimates, showAssignees, hasActions }) => {
|
|
1538
|
+
return [
|
|
1539
|
+
[void 0, toggle ? "var(--dx-half-block-size)" : "0px"],
|
|
1540
|
+
gutter > 0 && ["gutter", `calc(${gutter} * var(--dx-block-size))`],
|
|
1541
|
+
["status", "var(--dx-block-size)"],
|
|
1542
|
+
["title", "minmax(0, 1fr)"],
|
|
1543
|
+
["artifacts", "auto"],
|
|
1544
|
+
["assignee", showAssignees ? "var(--dx-block-size)" : "0px"],
|
|
1545
|
+
showEstimates && ["estimate", "var(--dx-block-size)"],
|
|
1546
|
+
["priority", "var(--dx-block-size)"],
|
|
1547
|
+
hasActions && ["actions", "var(--dx-block-size)"]
|
|
1548
|
+
].filter((track) => track !== false).map(([name, size]) => name ? `[${name}] ${size}` : size).join(" ");
|
|
1549
|
+
};
|
|
1550
|
+
var TaskListContent = (_props) => {
|
|
1551
|
+
const { tasks, groupByStatus, groups, hierarchical, selected, checked, showGroupLabels, showOrdinals, showDescription, descriptionComponents, showGutter, columns, isCollapsed, onCollapseToggle, onTaskCheck, onTaskSelect, onTaskUpdate, onTaskMove } = useTaskListContext("TaskList.Content");
|
|
1552
|
+
const collapsed = useMemo(() => new Set([...tasks.map((task) => task.id), ...(groups ?? []).map((group) => taskGroupNodeId(group))].filter(isCollapsed)), [
|
|
1553
|
+
tasks,
|
|
1554
|
+
groups,
|
|
1555
|
+
isCollapsed
|
|
1556
|
+
]);
|
|
1557
|
+
const grouping = !groups && !hierarchical && groupByStatus && showGroupLabels ? STATUS_ORDER : void 0;
|
|
1558
|
+
const ordinals = useMemo(() => {
|
|
1559
|
+
const ordered = groups ? flattenVisibleTasks(buildTaskGroups(groups, hierarchical), collapsed) : grouping ? grouping.flatMap((status) => tasks.filter((task) => (task.status ?? "todo") === status)) : hierarchical ? flattenVisibleTasks(buildTaskForest(tasks), collapsed) : tasks;
|
|
1560
|
+
return new Map(ordered.flatMap((task, index) => index < MAX_ORDINAL ? [[task.id, index + 1]] : []));
|
|
1561
|
+
}, [
|
|
1562
|
+
tasks,
|
|
1563
|
+
collapsed,
|
|
1564
|
+
groups,
|
|
1565
|
+
grouping,
|
|
1566
|
+
hierarchical
|
|
1567
|
+
]);
|
|
1568
|
+
return /* @__PURE__ */ jsx(TaskTreeNode, {
|
|
1569
|
+
descriptionComponents,
|
|
1570
|
+
hierarchical,
|
|
1571
|
+
groupByStatus: grouping,
|
|
1572
|
+
groups,
|
|
1573
|
+
tasks,
|
|
1574
|
+
collapsed,
|
|
1575
|
+
toggle: hierarchical || !!groups,
|
|
1576
|
+
columns,
|
|
1577
|
+
ordinals: showOrdinals ? ordinals : EMPTY_ORDINALS,
|
|
1578
|
+
selected,
|
|
1579
|
+
checked,
|
|
1580
|
+
showDescription,
|
|
1581
|
+
renderItem: TaskListItem,
|
|
1582
|
+
translationKey,
|
|
1583
|
+
onCollapseToggle,
|
|
1584
|
+
onTaskCheck,
|
|
1585
|
+
onTaskSelect,
|
|
1586
|
+
onTaskUpdate,
|
|
1587
|
+
onTaskMove
|
|
1588
|
+
});
|
|
1589
|
+
};
|
|
1590
|
+
TaskListContent.displayName = "TaskList.Content";
|
|
1591
|
+
var TaskListGroupLabel = Util.composable(({ children, ...props }, forwardedRef) => {
|
|
1592
|
+
const { className, ...rest } = Util.composableProps(props);
|
|
1593
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1594
|
+
...rest,
|
|
1595
|
+
className: mx("col-span-full min-h-(--dx-control) flex items-center text-sm text-fg-muted", className),
|
|
1596
|
+
ref: forwardedRef,
|
|
1597
|
+
children: /* @__PURE__ */ jsx("span", { children })
|
|
1598
|
+
});
|
|
1599
|
+
});
|
|
1600
|
+
TaskListGroupLabel.displayName = "TaskList.GroupLabel";
|
|
1601
|
+
/**
|
|
1602
|
+
* A task's row in the tree, read-only apart from its own controls: each cell placed by its track's name, as the edit
|
|
1603
|
+
* pane's are, so the pane's fields sit where these values are read.
|
|
1604
|
+
*/
|
|
1605
|
+
var TaskListItem = ({ node, indicator, ordinal, checked, translationKey: itemTranslationKey, showDescription, descriptionComponents, onTaskCheck, onTaskUpdate }) => {
|
|
1606
|
+
const { t } = Hooks.useTranslation(itemTranslationKey);
|
|
1607
|
+
const { showGutter, showMnemonics, showAssignees, showEstimates } = useTaskListContext("TaskList.Item");
|
|
1608
|
+
const task = node.task;
|
|
1609
|
+
const [snapshot] = useObject(task);
|
|
1610
|
+
const current = snapshot ?? task;
|
|
1611
|
+
if (!task || !current) return null;
|
|
1612
|
+
const description = showDescription ? current.description?.trim() || void 0 : void 0;
|
|
1613
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1614
|
+
indicator,
|
|
1615
|
+
showGutter && /* @__PURE__ */ jsxs(Layout.Flex, {
|
|
1616
|
+
center: true,
|
|
1617
|
+
gap: "xs",
|
|
1618
|
+
classNames: TRACK.gutter,
|
|
1619
|
+
children: [onTaskCheck && /* @__PURE__ */ jsx(TaskCheckbox, {
|
|
1620
|
+
task,
|
|
1621
|
+
checked,
|
|
1622
|
+
onCheckedChange: onTaskCheck
|
|
1623
|
+
}), (showMnemonics || ordinal !== void 0) && /* @__PURE__ */ jsx(TaskMnemonic, {
|
|
1624
|
+
task,
|
|
1625
|
+
ordinal: onTaskCheck ? void 0 : ordinal
|
|
1626
|
+
})]
|
|
1627
|
+
}),
|
|
1628
|
+
/* @__PURE__ */ jsx(TaskStatusControl, {
|
|
1629
|
+
task,
|
|
1630
|
+
onTaskUpdate,
|
|
1631
|
+
classNames: TRACK.status
|
|
1632
|
+
}),
|
|
1633
|
+
/* @__PURE__ */ jsx("span", {
|
|
1634
|
+
"data-testid": "taskList.item.title",
|
|
1635
|
+
"data-placeholder": t("task-title.placeholder"),
|
|
1636
|
+
className: mx(TRACK.title, "min-w-0 truncate px-(--dx-gap-size) self-center", "empty:before:text-placeholder empty:before:content-[attr(data-placeholder)]"),
|
|
1637
|
+
children: current.title
|
|
1638
|
+
}),
|
|
1639
|
+
/* @__PURE__ */ jsx(Layout.Flex, {
|
|
1640
|
+
align: "center",
|
|
1641
|
+
gap: "xs",
|
|
1642
|
+
classNames: mx(TRACK.artifacts, "ps-1"),
|
|
1643
|
+
"data-testid": "taskList.item.artifacts",
|
|
1644
|
+
children: /* @__PURE__ */ jsx(TaskListItemArtifacts, {
|
|
1645
|
+
task,
|
|
1646
|
+
filter: (artifact) => PullRequest.instanceOf(artifact)
|
|
1647
|
+
})
|
|
1648
|
+
}),
|
|
1649
|
+
showAssignees && current.assignee && /* @__PURE__ */ jsx(Layout.Grid, {
|
|
1650
|
+
center: true,
|
|
1651
|
+
classNames: TRACK.assignee,
|
|
1652
|
+
children: /* @__PURE__ */ jsx(TaskListAssignee, {
|
|
1653
|
+
assignee: current.assignee,
|
|
1654
|
+
iconOnly: true
|
|
1655
|
+
})
|
|
1656
|
+
}),
|
|
1657
|
+
showEstimates && /* @__PURE__ */ jsx(TaskEstimateControl, {
|
|
1658
|
+
task,
|
|
1659
|
+
classNames: TRACK.estimate
|
|
1660
|
+
}),
|
|
1661
|
+
/* @__PURE__ */ jsx(TaskPriorityIcon, {
|
|
1662
|
+
task,
|
|
1663
|
+
classNames: TRACK.priority
|
|
1664
|
+
}),
|
|
1665
|
+
/* @__PURE__ */ jsx(TaskListItemActions, {
|
|
1666
|
+
task,
|
|
1667
|
+
classNames: TRACK.actions
|
|
1668
|
+
}),
|
|
1669
|
+
/* @__PURE__ */ jsx(Layout.Flex, {
|
|
1670
|
+
align: "center",
|
|
1671
|
+
classNames: "col-[title] row-start-2 empty:hidden",
|
|
1672
|
+
"data-testid": "taskList.item.chips",
|
|
1673
|
+
children: /* @__PURE__ */ jsx(TaskListItemTags, {
|
|
1674
|
+
task,
|
|
1675
|
+
tags: Obj.getMeta(task).tags
|
|
1676
|
+
})
|
|
1677
|
+
}),
|
|
1678
|
+
description && /* @__PURE__ */ jsx(TaskDescription, {
|
|
1679
|
+
content: description,
|
|
1680
|
+
components: descriptionComponents,
|
|
1681
|
+
classNames: "col-[title/assignee] row-start-3 min-w-0 px-(--dx-gap-size) mb-1"
|
|
1682
|
+
})
|
|
1683
|
+
] });
|
|
1684
|
+
};
|
|
1685
|
+
var isMenuAction = (item) => "data" in item && typeof item.data === "function";
|
|
1686
|
+
/**
|
|
1687
|
+
* A row's contributed actions. One is a plain button — a `…` menu hiding a single item costs a click
|
|
1688
|
+
* to discover nothing — and several collapse into the overflow menu, matching the nav tree's rows.
|
|
1689
|
+
*/
|
|
1690
|
+
var TaskListItemActions = ({ task, classNames }) => {
|
|
1691
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
1692
|
+
const { getTaskActions } = useTaskListContext("TaskList.ItemActions");
|
|
1693
|
+
const actions = useMemo(() => getTaskActions?.(task) ?? [], [getTaskActions, task]);
|
|
1694
|
+
if (actions.length === 0) return null;
|
|
1695
|
+
const [only] = actions;
|
|
1696
|
+
if (actions.length === 1 && isMenuAction(only)) return /* @__PURE__ */ jsx(Tree.ItemActions, { children: /* @__PURE__ */ jsx(Layout.Block, {
|
|
1697
|
+
classNames,
|
|
1698
|
+
children: /* @__PURE__ */ jsx(Button.Root, {
|
|
1699
|
+
variant: "ghost",
|
|
1700
|
+
iconOnly: true,
|
|
1701
|
+
icon: only.properties?.icon ?? fallbackIcon,
|
|
1702
|
+
label: Theme.toLocalizedString(only.properties?.label, t),
|
|
1703
|
+
"data-testid": only.properties?.testId,
|
|
1704
|
+
onClick: (event) => {
|
|
1705
|
+
event.stopPropagation();
|
|
1706
|
+
executeMenuAction(only);
|
|
1707
|
+
}
|
|
1708
|
+
})
|
|
1709
|
+
}) });
|
|
1710
|
+
return /* @__PURE__ */ jsx(Tree.ItemActions, { children: /* @__PURE__ */ jsx(Layout.Block, {
|
|
1711
|
+
classNames,
|
|
1712
|
+
children: /* @__PURE__ */ jsx(ActionMenu, {
|
|
1713
|
+
deferUntilOpen: true,
|
|
1714
|
+
actions,
|
|
1715
|
+
children: /* @__PURE__ */ jsx(Button.Root, {
|
|
600
1716
|
variant: "ghost",
|
|
601
|
-
density: "sm",
|
|
602
|
-
icon,
|
|
603
1717
|
iconOnly: true,
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
1718
|
+
icon: "ph--dots-three-vertical--regular",
|
|
1719
|
+
label: t("task-actions.label"),
|
|
1720
|
+
"data-testid": "taskList.item.actions",
|
|
1721
|
+
onClick: (event) => event.stopPropagation()
|
|
1722
|
+
})
|
|
1723
|
+
})
|
|
1724
|
+
}) });
|
|
1725
|
+
};
|
|
1726
|
+
TaskListItemActions.displayName = "TaskList.ItemActions";
|
|
1727
|
+
/**
|
|
1728
|
+
* What a task produced, one tag each. Queried rather than read off `ref.target`: on a cold load the
|
|
1729
|
+
* targets are not in memory yet, and a sync read would leave the row permanently empty.
|
|
1730
|
+
*/
|
|
1731
|
+
var TaskListItemArtifacts = ({ task, filter }) => {
|
|
1732
|
+
const db = Obj.getDatabase(task);
|
|
1733
|
+
const ids = useMemo(() => (task.artifacts ?? []).flatMap((ref) => {
|
|
1734
|
+
const id = Task.refEntityId(ref);
|
|
1735
|
+
return id ? [id] : [];
|
|
1736
|
+
}), [task.artifacts]);
|
|
1737
|
+
const queried = useQuery(ids.length > 0 ? db : void 0, Filter.id(...ids));
|
|
1738
|
+
const resolved = db ? queried : (task.artifacts ?? []).flatMap((ref) => ref.target ? [ref.target] : []);
|
|
1739
|
+
const artifacts = filter ? resolved.filter(filter) : resolved;
|
|
1740
|
+
return /* @__PURE__ */ jsx(Fragment, { children: artifacts.map((artifact) => /* @__PURE__ */ jsx(ArtifactTag, { artifact }, artifact.id)) });
|
|
1741
|
+
};
|
|
1742
|
+
TaskListItemArtifacts.displayName = "TaskList.ItemArtifacts";
|
|
1743
|
+
/**
|
|
1744
|
+
* Everything a task carries as a chip: its tags, what it produced, and who has it.
|
|
1745
|
+
*/
|
|
1746
|
+
var TaskTags = ({ task }) => {
|
|
1747
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(TaskListItemTags, {
|
|
1748
|
+
task,
|
|
1749
|
+
tags: Obj.getMeta(task).tags
|
|
1750
|
+
}), /* @__PURE__ */ jsx(TaskListItemArtifacts, {
|
|
1751
|
+
task,
|
|
1752
|
+
filter: (artifact) => PullRequest.instanceOf(artifact)
|
|
1753
|
+
})] });
|
|
1754
|
+
};
|
|
1755
|
+
TaskTags.displayName = "TaskList.Tags";
|
|
1756
|
+
/**
|
|
1757
|
+
* The task's tags, as chips in the same cell as its artifacts and assignee. Queried by id for the
|
|
1758
|
+
* reason artifacts are: a tag's target is not in memory on a cold load.
|
|
1759
|
+
*/
|
|
1760
|
+
var TaskListItemTags = ({ task, tags }) => {
|
|
1761
|
+
const db = Obj.getDatabase(task);
|
|
1762
|
+
const ids = useMemo(() => tags.flatMap((ref) => {
|
|
1763
|
+
const id = Task.refEntityId(ref);
|
|
1764
|
+
return id ? [id] : [];
|
|
1765
|
+
}), [tags]);
|
|
1766
|
+
const queried = useQuery(ids.length > 0 ? db : void 0, Filter.id(...ids));
|
|
1767
|
+
const resolved = db ? queried : tags.flatMap((ref) => ref.target ? [ref.target] : []);
|
|
1768
|
+
const labelled = useMemo(() => resolved.filter((object) => Obj.instanceOf(Tag.Tag, object)).sort(Tag.sortTags), [resolved]);
|
|
1769
|
+
return /* @__PURE__ */ jsx(Fragment, { children: labelled.map((tag) => /* @__PURE__ */ jsx(Tag$1.Tag, {
|
|
1770
|
+
hue: toHue(tag.hue),
|
|
1771
|
+
"data-testid": "taskList.item.tag",
|
|
1772
|
+
children: tag.label
|
|
1773
|
+
}, tag.id)) });
|
|
1774
|
+
};
|
|
1775
|
+
TaskListItemTags.displayName = "TaskList.ItemTags";
|
|
1776
|
+
/**
|
|
1777
|
+
* One artifact, as a tag: hovering shows the object's card and clicking opens the object. The tag
|
|
1778
|
+
* takes no tab stop of its own, since inside a listbox option it would split the row into several
|
|
1779
|
+
* arrow-key stops.
|
|
1780
|
+
*
|
|
1781
|
+
* A {@link PullRequest.PullRequest} renders as its `#number` pill — the form a PR link takes in
|
|
1782
|
+
* markdown — so a row reads the same as the text that references it.
|
|
1783
|
+
*/
|
|
1784
|
+
var ArtifactTag = ({ artifact }) => {
|
|
1785
|
+
const label = Obj.getLabel(artifact) ?? Obj.getTypename(artifact) ?? "";
|
|
1786
|
+
const anchor = usePreviewAnchor({
|
|
1787
|
+
eid: Obj.getURI(artifact).toString(),
|
|
1788
|
+
label
|
|
1789
|
+
});
|
|
1790
|
+
if (PullRequest.instanceOf(artifact)) return /* @__PURE__ */ jsx(Button.Root, {
|
|
1791
|
+
...anchor,
|
|
1792
|
+
hue: "neutral",
|
|
1793
|
+
size: "sm",
|
|
1794
|
+
classNames: "bg-input-surface text-fg font-normal ring-inset ring ring-neutral-border hover:bg-hover-surface hover:ring-info-border",
|
|
1795
|
+
icon: "ph--git-pull-request--regular",
|
|
1796
|
+
iconClassNames: pullRequestStateStyle[artifact.state],
|
|
1797
|
+
label: `#${artifact.number}`,
|
|
1798
|
+
tabIndex: -1
|
|
1799
|
+
});
|
|
1800
|
+
return /* @__PURE__ */ jsx(Tag$1.Tag, {
|
|
1801
|
+
...anchor,
|
|
1802
|
+
hue: "amber",
|
|
1803
|
+
classNames: "cursor-pointer",
|
|
1804
|
+
children: label
|
|
1805
|
+
});
|
|
1806
|
+
};
|
|
1807
|
+
ArtifactTag.displayName = "TaskList.ArtifactTag";
|
|
1808
|
+
/** GitHub's own state colours, so the icon reads as open, merged or closed at a glance. */
|
|
1809
|
+
var pullRequestStateStyle = {
|
|
1810
|
+
open: "text-green-500",
|
|
1811
|
+
merged: "text-violet-500",
|
|
1812
|
+
closed: "text-red-500",
|
|
1813
|
+
draft: "text-fg-muted"
|
|
1814
|
+
};
|
|
1815
|
+
var TaskListAssignee = Util.composable(({ assignee, iconOnly }, _forwardedRef) => {
|
|
1816
|
+
const { label, icon, agent, session: harness } = useAssigneeDisplay(assignee);
|
|
1817
|
+
const [session] = useObject(assignee.subject);
|
|
1818
|
+
const anchor = usePreviewAnchor({
|
|
1819
|
+
eid: session && Obj.getURI(session).toString(),
|
|
1820
|
+
label: label ?? "",
|
|
1821
|
+
title: harness?.title ?? label
|
|
1822
|
+
});
|
|
1823
|
+
if (!label && !agent) return null;
|
|
1824
|
+
const hue = agent ? "purple" : "indigo";
|
|
1825
|
+
if (iconOnly && label) return /* @__PURE__ */ jsx(Button.Root, {
|
|
1826
|
+
iconOnly: true,
|
|
1827
|
+
icon,
|
|
1828
|
+
hue,
|
|
1829
|
+
label,
|
|
1830
|
+
showTooltip: !session,
|
|
1831
|
+
"data-testid": "taskList.item.assignee",
|
|
1832
|
+
...session && anchor
|
|
1833
|
+
});
|
|
1834
|
+
return /* @__PURE__ */ jsxs(Tag$1.Tag, {
|
|
1835
|
+
hue,
|
|
1836
|
+
"data-testid": "taskList.item.assignee",
|
|
1837
|
+
...session && {
|
|
1838
|
+
...anchor,
|
|
1839
|
+
role: "button",
|
|
1840
|
+
tabIndex: 0
|
|
1841
|
+
},
|
|
1842
|
+
classNames: session && "cursor-pointer",
|
|
1843
|
+
children: [agent && /* @__PURE__ */ jsx(Icon.Icon, {
|
|
1844
|
+
icon,
|
|
1845
|
+
classNames: "inline-block size-4 me-1"
|
|
1846
|
+
}), label]
|
|
1847
|
+
});
|
|
1848
|
+
});
|
|
1849
|
+
TaskListAssignee.displayName = "TaskList.Assignee";
|
|
1850
|
+
var TaskList = {
|
|
1851
|
+
Root: TaskListRoot,
|
|
1852
|
+
Viewport: TaskListViewport,
|
|
1853
|
+
Content: TaskListContent,
|
|
1854
|
+
GroupLabel: TaskListGroupLabel,
|
|
1855
|
+
Assignee: TaskListAssignee,
|
|
1856
|
+
Editor: TaskListEditor
|
|
1857
|
+
};
|
|
1858
|
+
//#endregion
|
|
1859
|
+
//#region src/components/TaskProperties/TaskProperties.tsx
|
|
1860
|
+
/** The glyph for an estimate, which the list renders as letters and has none of its own. */
|
|
1861
|
+
var ESTIMATE_ICON = "ph--ruler--regular";
|
|
1862
|
+
/**
|
|
1863
|
+
* A task's own fields as a labelled list: one row each for status, priority and estimate, the glyph
|
|
1864
|
+
* naming the field and the value beside it.
|
|
1865
|
+
*
|
|
1866
|
+
* A list rather than the row of icon buttons the task list uses. A row has one line and many tasks,
|
|
1867
|
+
* so a glyph alone is the only thing that fits and the reader learns the ramp; a detail pane has one
|
|
1868
|
+
* task and the room to say what the glyph means — and an unset field can then invite the value
|
|
1869
|
+
* ("Set estimate") instead of showing a dot that reads as a value of its own.
|
|
1870
|
+
*/
|
|
1871
|
+
var TaskProperties = ({ task, members = [], onTaskUpdate, classNames }) => {
|
|
1872
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
1873
|
+
const status = task.status ?? "todo";
|
|
1874
|
+
const priority = task.priority ?? void 0;
|
|
1875
|
+
const estimate = task.estimate ?? void 0;
|
|
1876
|
+
const assignee = task.assignee ?? void 0;
|
|
1877
|
+
const db = Obj.getDatabase(task);
|
|
1878
|
+
const people = useQuery(onTaskUpdate ? db : void 0, Filter.type(Person.Person));
|
|
1879
|
+
const { label: assigneeLabel, icon: assigneeIcon } = useAssigneeDisplay(assignee);
|
|
1880
|
+
const { createdAt } = Obj.getMeta(task);
|
|
1881
|
+
return /* @__PURE__ */ jsx(Layout.Container, {
|
|
1882
|
+
asChild: true,
|
|
1883
|
+
gutter: "inherit",
|
|
1884
|
+
gap: "sm",
|
|
1885
|
+
classNames,
|
|
1886
|
+
"data-testid": "taskList.properties",
|
|
1887
|
+
children: /* @__PURE__ */ jsxs("section", { children: [
|
|
1888
|
+
/* @__PURE__ */ jsx(Typography.Text, {
|
|
1889
|
+
asChild: true,
|
|
1890
|
+
tone: "subtle",
|
|
1891
|
+
classNames: "dx-label py-0",
|
|
1892
|
+
children: /* @__PURE__ */ jsx("h2", { children: t("task-properties.label") })
|
|
630
1893
|
}),
|
|
631
|
-
current.assignee ? /* @__PURE__ */ jsx("span", {
|
|
632
|
-
className: "flex h-8 items-center",
|
|
633
|
-
children: /* @__PURE__ */ jsx(TaskListAssignee, { assignee: current.assignee })
|
|
634
|
-
}) : /* @__PURE__ */ jsx("div", {}),
|
|
635
1894
|
/* @__PURE__ */ jsxs("div", {
|
|
636
|
-
className: "
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
1895
|
+
className: mx(TASK_GRID, "items-center min-h-(--dx-control-sm) min-w-0"),
|
|
1896
|
+
"data-testid": "taskList.property.reference",
|
|
1897
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1898
|
+
className: TASK_GRID_ICON,
|
|
1899
|
+
children: /* @__PURE__ */ jsx(TaskMnemonic, { task })
|
|
1900
|
+
}), /* @__PURE__ */ jsx(Typography.Text, {
|
|
1901
|
+
tone: "muted",
|
|
1902
|
+
classNames: "text-sm",
|
|
1903
|
+
children: Obj.getMnemonic(task)
|
|
643
1904
|
})]
|
|
644
1905
|
}),
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1906
|
+
createdAt !== void 0 && /* @__PURE__ */ jsx(TaskProperty, {
|
|
1907
|
+
icon: "ph--calendar-plus--regular",
|
|
1908
|
+
label: /* @__PURE__ */ jsx(Typography.Timestamp, { date: createdAt }),
|
|
1909
|
+
unset: true,
|
|
1910
|
+
testId: "taskList.property.created"
|
|
1911
|
+
}),
|
|
1912
|
+
/* @__PURE__ */ jsx(TaskProperty, {
|
|
1913
|
+
icon: statusIcon(status),
|
|
1914
|
+
iconClassNames: statusTextStyle(status),
|
|
1915
|
+
label: t(`status-${status}.label`),
|
|
1916
|
+
testId: "taskList.property.status",
|
|
1917
|
+
actions: onTaskUpdate && (() => Task.StatusOptions.map(({ id }) => createMenuAction(`status-${id}`, () => onTaskUpdate(task, { status: id }), {
|
|
1918
|
+
label: t(`status-${id}.label`),
|
|
1919
|
+
icon: statusIcon(id),
|
|
1920
|
+
iconClassNames: statusTextStyle(id),
|
|
1921
|
+
checked: status === id
|
|
1922
|
+
})))
|
|
1923
|
+
}),
|
|
1924
|
+
/* @__PURE__ */ jsx(TaskProperty, {
|
|
1925
|
+
icon: assignee ? assigneeIcon : UNSET_ICON,
|
|
1926
|
+
label: assigneeLabel ?? t("set-assignee.label"),
|
|
1927
|
+
unset: !assignee,
|
|
1928
|
+
testId: "taskList.property.assignee",
|
|
1929
|
+
actions: onTaskUpdate && (() => [
|
|
1930
|
+
createMenuAction("assignee-none", () => onTaskUpdate(task, { assignee: null }), {
|
|
1931
|
+
label: t("assignee-none.label"),
|
|
1932
|
+
icon: "ph--asterisk--regular",
|
|
1933
|
+
checked: !assignee
|
|
1934
|
+
}),
|
|
1935
|
+
...assignee && !assignee.contact && !members.some((member) => member.did === assignee.identityDid) && assigneeLabel ? [createMenuAction("assignee-current", () => {}, {
|
|
1936
|
+
label: assigneeLabel,
|
|
1937
|
+
icon: assigneeIcon,
|
|
1938
|
+
checked: true,
|
|
1939
|
+
testId: "taskList.assignee.current"
|
|
1940
|
+
})] : [],
|
|
1941
|
+
...members.map((member) => createMenuAction(`assignee-member-${member.did}`, () => onTaskUpdate(task, { assignee: {
|
|
1942
|
+
identityDid: member.did,
|
|
1943
|
+
name: member.name
|
|
1944
|
+
} }), {
|
|
1945
|
+
label: member.name ?? shortDid(member.did),
|
|
1946
|
+
icon: "ph--user--regular",
|
|
1947
|
+
checked: assignee?.identityDid === member.did,
|
|
1948
|
+
testId: "taskList.assignee.member"
|
|
1949
|
+
})),
|
|
1950
|
+
...people.map((person) => createMenuAction(`assignee-${person.id}`, () => onTaskUpdate(task, { assignee: { contact: Ref.make(person) } }), {
|
|
1951
|
+
label: Obj.getLabel(person) ?? person.id,
|
|
1952
|
+
icon: "ph--user--regular",
|
|
1953
|
+
checked: Task.refEntityId(assignee?.contact) === person.id
|
|
1954
|
+
}))
|
|
1955
|
+
])
|
|
651
1956
|
}),
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
1957
|
+
/* @__PURE__ */ jsx(TaskProperty, {
|
|
1958
|
+
icon: priorityIcon(priority),
|
|
1959
|
+
iconClassNames: priorityTextStyle(priority),
|
|
1960
|
+
label: priority ? t(`priority-${priority}.label`) : t("set-priority.label"),
|
|
1961
|
+
unset: !priority,
|
|
1962
|
+
testId: "taskList.property.priority",
|
|
1963
|
+
actions: onTaskUpdate && (() => [Task.NullOption, ...Task.PriorityOptions].map(({ id, icon }) => createMenuAction(`priority-${id}`, () => onTaskUpdate(task, { priority: id === "none" ? null : id }), {
|
|
1964
|
+
label: t(`priority-${id}.label`),
|
|
1965
|
+
icon: icon ?? "ph--asterisk--regular",
|
|
1966
|
+
iconClassNames: priorityTextStyle(id),
|
|
1967
|
+
checked: (priority ?? "none") === id
|
|
1968
|
+
})))
|
|
655
1969
|
}),
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1970
|
+
/* @__PURE__ */ jsx(TaskProperty, {
|
|
1971
|
+
icon: estimate ? ESTIMATE_ICON : UNSET_ICON,
|
|
1972
|
+
iconClassNames: estimateTextStyle(estimate),
|
|
1973
|
+
label: estimate ? estimate.toUpperCase() : t("set-estimate.label"),
|
|
1974
|
+
unset: !estimate,
|
|
1975
|
+
testId: "taskList.property.estimate",
|
|
1976
|
+
actions: onTaskUpdate && (() => [Task.NullOption, ...Task.EstimateOptions].map(({ id, title }) => createMenuAction(`estimate-${id}`, () => onTaskUpdate(task, { estimate: id === "none" ? null : id }), {
|
|
1977
|
+
label: title,
|
|
1978
|
+
checked: (estimate ?? "none") === id
|
|
1979
|
+
})))
|
|
660
1980
|
})
|
|
661
|
-
]
|
|
1981
|
+
] })
|
|
662
1982
|
});
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
var
|
|
666
|
-
|
|
667
|
-
className:
|
|
668
|
-
children: /* @__PURE__ */ jsx(
|
|
1983
|
+
};
|
|
1984
|
+
TaskProperties.displayName = "TaskList.Properties";
|
|
1985
|
+
var TaskProperty = ({ icon, iconClassNames, label, unset, testId, actions }) => {
|
|
1986
|
+
const content = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
|
|
1987
|
+
className: TASK_GRID_ICON,
|
|
1988
|
+
children: /* @__PURE__ */ jsx(Icon.Icon, {
|
|
1989
|
+
icon,
|
|
1990
|
+
classNames: mx(unset ? "text-fg-muted" : iconClassNames)
|
|
1991
|
+
})
|
|
1992
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1993
|
+
className: mx("min-w-0 pe-1.5 text-sm truncate", unset && "text-fg-muted"),
|
|
1994
|
+
children: label
|
|
1995
|
+
})] });
|
|
1996
|
+
if (!actions) return /* @__PURE__ */ jsx("div", {
|
|
1997
|
+
className: mx(TASK_GRID, "items-center min-h-(--dx-control-sm) min-w-0"),
|
|
1998
|
+
"data-testid": testId,
|
|
1999
|
+
children: content
|
|
2000
|
+
});
|
|
2001
|
+
return /* @__PURE__ */ jsx(ActionMenu, {
|
|
2002
|
+
deferUntilOpen: true,
|
|
2003
|
+
actions,
|
|
2004
|
+
children: /* @__PURE__ */ jsx(Button.Root, {
|
|
669
2005
|
variant: "ghost",
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
2006
|
+
size: "sm",
|
|
2007
|
+
classNames: mx(TASK_GRID, "items-center w-fit min-w-0 justify-start px-0"),
|
|
2008
|
+
"data-testid": testId,
|
|
2009
|
+
children: content
|
|
673
2010
|
})
|
|
674
2011
|
});
|
|
675
2012
|
};
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
2013
|
+
//#endregion
|
|
2014
|
+
//#region src/components/TaskQuestion/TaskQuestion.tsx
|
|
2015
|
+
/**
|
|
2016
|
+
* Stops an event at the question: it sits inside a listbox row, whose click selects the task and
|
|
2017
|
+
* whose arrow keys move the selection, and typing an answer must do neither.
|
|
2018
|
+
*/
|
|
2019
|
+
var stop = (event) => event.stopPropagation();
|
|
2020
|
+
/**
|
|
2021
|
+
* A question from a task's history: why it was asked, and the means to answer it. Answered, it
|
|
2022
|
+
* collapses to the question and the answer, since the exchange is then a record rather than a
|
|
2023
|
+
* prompt.
|
|
2024
|
+
*
|
|
2025
|
+
* The free-form field is always present, never a fallback revealed by a "something else" option:
|
|
2026
|
+
* the options are the asker's guesses, and making the reader hunt for the escape hatch pressures
|
|
2027
|
+
* them into picking a wrong one.
|
|
2028
|
+
*/
|
|
2029
|
+
var TaskQuestion = ({ classNames, thread: { question, answer }, busy, message, compact, onAnswer }) => {
|
|
2030
|
+
const { t } = Hooks.useTranslation(translationKey);
|
|
2031
|
+
const [text, setText] = useState("");
|
|
2032
|
+
const handleSubmit = useCallback((value) => {
|
|
2033
|
+
if (!busy && value.trim().length > 0) onAnswer?.(value);
|
|
2034
|
+
}, [busy, onAnswer]);
|
|
680
2035
|
const handleKeyDown = useCallback((event) => {
|
|
681
|
-
|
|
682
|
-
if (event.key === "Enter"
|
|
683
|
-
|
|
684
|
-
|
|
2036
|
+
event.stopPropagation();
|
|
2037
|
+
if (event.key === "Enter") {
|
|
2038
|
+
event.preventDefault();
|
|
2039
|
+
handleSubmit(text);
|
|
685
2040
|
}
|
|
686
|
-
}, [
|
|
687
|
-
if (
|
|
2041
|
+
}, [handleSubmit, text]);
|
|
2042
|
+
if (compact) return /* @__PURE__ */ jsxs("div", {
|
|
2043
|
+
role: "group",
|
|
2044
|
+
"aria-label": question.text,
|
|
2045
|
+
className: mx("flex flex-col w-full gap-1 text-sm", classNames),
|
|
2046
|
+
"data-testid": "task-question",
|
|
2047
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
2048
|
+
className: "flex items-center gap-2 min-w-0",
|
|
2049
|
+
children: [/* @__PURE__ */ jsx(Icon.Icon, {
|
|
2050
|
+
icon: "ph--question--regular",
|
|
2051
|
+
valence: "warning"
|
|
2052
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
2053
|
+
className: "font-medium truncate",
|
|
2054
|
+
title: question.text,
|
|
2055
|
+
children: question.text
|
|
2056
|
+
})]
|
|
2057
|
+
}), answer && /* @__PURE__ */ jsxs("div", {
|
|
2058
|
+
className: "flex items-center gap-2 min-w-0",
|
|
2059
|
+
"data-testid": "task-question.answer",
|
|
2060
|
+
children: [/* @__PURE__ */ jsx(Icon.Icon, {
|
|
2061
|
+
icon: "ph--check-circle--regular",
|
|
2062
|
+
valence: "success"
|
|
2063
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
2064
|
+
className: "truncate",
|
|
2065
|
+
title: answer.answer,
|
|
2066
|
+
children: answer.answer
|
|
2067
|
+
})]
|
|
2068
|
+
})]
|
|
2069
|
+
});
|
|
688
2070
|
return /* @__PURE__ */ jsxs("div", {
|
|
689
|
-
|
|
690
|
-
"
|
|
691
|
-
className: mx("
|
|
692
|
-
|
|
2071
|
+
role: "group",
|
|
2072
|
+
"aria-label": question.text,
|
|
2073
|
+
className: mx(TASK_GRID, "gap-y-2 text-sm", classNames),
|
|
2074
|
+
"data-testid": "task-question",
|
|
2075
|
+
onClick: stop,
|
|
2076
|
+
onPointerDown: stop,
|
|
2077
|
+
onKeyDown: stop,
|
|
693
2078
|
children: [
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
2079
|
+
/* @__PURE__ */ jsx("div", {
|
|
2080
|
+
className: TASK_GRID_ICON,
|
|
2081
|
+
children: /* @__PURE__ */ jsx(Icon.Icon, {
|
|
2082
|
+
icon: "ph--question--regular",
|
|
2083
|
+
valence: "warning"
|
|
2084
|
+
})
|
|
2085
|
+
}),
|
|
2086
|
+
/* @__PURE__ */ jsx("span", {
|
|
2087
|
+
className: "font-medium wrap-break-word min-w-0",
|
|
2088
|
+
children: question.text
|
|
2089
|
+
}),
|
|
2090
|
+
question.context && !answer && /* @__PURE__ */ jsx("p", {
|
|
2091
|
+
className: mx("col-start-2", "text-fg-muted wrap-break-word line-clamp-3 min-w-0"),
|
|
2092
|
+
children: question.context
|
|
2093
|
+
}),
|
|
2094
|
+
answer ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("div", {
|
|
2095
|
+
className: TASK_GRID_ICON,
|
|
2096
|
+
children: /* @__PURE__ */ jsx(Icon.Icon, {
|
|
2097
|
+
icon: "ph--check-circle--regular",
|
|
2098
|
+
valence: "success"
|
|
2099
|
+
})
|
|
2100
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
2101
|
+
className: "wrap-break-word min-w-0",
|
|
2102
|
+
"data-testid": "task-question.answer",
|
|
2103
|
+
children: answer.answer
|
|
2104
|
+
})] }) : onAnswer && /* @__PURE__ */ jsxs("div", {
|
|
2105
|
+
className: mx("col-start-2", "flex flex-col gap-2 min-w-0"),
|
|
2106
|
+
children: [question.options && question.options.length > 0 && /* @__PURE__ */ jsx("div", {
|
|
2107
|
+
role: "list",
|
|
2108
|
+
"aria-label": question.text,
|
|
2109
|
+
className: "flex flex-col gap-1 min-w-0",
|
|
2110
|
+
children: question.options.map((option, index) => /* @__PURE__ */ jsx("div", {
|
|
2111
|
+
role: "listitem",
|
|
2112
|
+
className: "min-w-0",
|
|
2113
|
+
children: /* @__PURE__ */ jsxs(Button.Root, {
|
|
2114
|
+
variant: "default",
|
|
2115
|
+
disabled: busy,
|
|
2116
|
+
classNames: "w-full min-w-0 h-auto py-1.5 justify-start text-start whitespace-normal",
|
|
2117
|
+
"data-testid": "task-question.option",
|
|
2118
|
+
onClick: () => handleSubmit(option.title),
|
|
2119
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
2120
|
+
className: "shrink-0 tabular-nums text-fg-muted self-start",
|
|
2121
|
+
children: [index + 1, "."]
|
|
2122
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
2123
|
+
className: "grow min-w-0 flex flex-col gap-0.5 text-start",
|
|
2124
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
2125
|
+
className: "font-medium wrap-break-word",
|
|
2126
|
+
children: option.title
|
|
2127
|
+
}), option.description && /* @__PURE__ */ jsx("div", {
|
|
2128
|
+
className: "text-xs text-fg-muted wrap-break-word leading-snug",
|
|
2129
|
+
children: option.description
|
|
2130
|
+
})]
|
|
2131
|
+
})]
|
|
2132
|
+
})
|
|
2133
|
+
}, option.title))
|
|
2134
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
2135
|
+
className: "flex flex-wrap gap-1",
|
|
2136
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
2137
|
+
className: "flex-[1_1_10rem] min-w-0",
|
|
2138
|
+
children: /* @__PURE__ */ jsxs(Field.Root, { children: [/* @__PURE__ */ jsx(Field.Label, {
|
|
2139
|
+
srOnly: true,
|
|
2140
|
+
children: t("question-answer.label")
|
|
2141
|
+
}), /* @__PURE__ */ jsx(Input.Root, {
|
|
2142
|
+
value: text,
|
|
2143
|
+
disabled: busy,
|
|
2144
|
+
placeholder: t("question-answer.placeholder"),
|
|
2145
|
+
"data-testid": "task-question.input",
|
|
2146
|
+
onChange: (event) => setText(event.target.value),
|
|
2147
|
+
onKeyDown: handleKeyDown
|
|
2148
|
+
})] })
|
|
2149
|
+
}), /* @__PURE__ */ jsx(Button.Root, {
|
|
2150
|
+
variant: "primary",
|
|
2151
|
+
disabled: busy || text.trim() === "",
|
|
2152
|
+
"data-testid": "task-question.submit",
|
|
2153
|
+
onClick: () => handleSubmit(text),
|
|
2154
|
+
children: t("question-submit.label")
|
|
2155
|
+
})]
|
|
2156
|
+
})]
|
|
699
2157
|
}),
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
onChange: (event) => setTitle(event.target.value),
|
|
706
|
-
onKeyDown: handleKeyDown
|
|
707
|
-
}) })
|
|
2158
|
+
message && /* @__PURE__ */ jsx("p", {
|
|
2159
|
+
className: mx("col-start-2", "text-fg-muted min-w-0"),
|
|
2160
|
+
"data-testid": "task-question.message",
|
|
2161
|
+
children: message
|
|
2162
|
+
})
|
|
708
2163
|
]
|
|
709
2164
|
});
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
Item: TaskListItem,
|
|
734
|
-
Create: TaskListCreate,
|
|
735
|
-
Assignee: TaskListAssignee
|
|
2165
|
+
};
|
|
2166
|
+
TaskQuestion.displayName = "TaskQuestion";
|
|
2167
|
+
//#endregion
|
|
2168
|
+
//#region src/util/format-date.ts
|
|
2169
|
+
/** Beyond this, "x days ago" stops locating an event and the calendar date does it better. */
|
|
2170
|
+
var RELATIVE_DAYS = 3;
|
|
2171
|
+
/**
|
|
2172
|
+
* A timestamp as a reader scans it: recent activity as a distance ("2 hours ago"), older activity as
|
|
2173
|
+
* a local calendar date.
|
|
2174
|
+
*
|
|
2175
|
+
* The date is formatted in the reader's own locale and zone (`toLocaleDateString`), since it is read
|
|
2176
|
+
* as "when did this happen to me" rather than as a record to cite; an unparseable value is returned
|
|
2177
|
+
* as it came, because a log line is decoration and never the source of truth.
|
|
2178
|
+
*/
|
|
2179
|
+
var formatRelative = (date, { relativeDays = 3, now } = {}) => {
|
|
2180
|
+
const parsed = typeof date === "string" ? new Date(date) : date;
|
|
2181
|
+
if (Number.isNaN(parsed.getTime())) return typeof date === "string" ? date : "";
|
|
2182
|
+
const reference = now ?? /* @__PURE__ */ new Date();
|
|
2183
|
+
return Math.abs(differenceInCalendarDays(reference, parsed)) > relativeDays ? parsed.toLocaleDateString(void 0, {
|
|
2184
|
+
year: "numeric",
|
|
2185
|
+
month: "short",
|
|
2186
|
+
day: "numeric"
|
|
2187
|
+
}) : formatDistance(parsed, reference, { addSuffix: true });
|
|
736
2188
|
};
|
|
737
2189
|
//#endregion
|
|
738
|
-
export {
|
|
2190
|
+
export { RELATIVE_DAYS, TASK_GRID, TASK_GRID_CONTENT, TASK_GRID_ICON, TaskHistory, TaskList, TaskProperties, TaskQuestion, TaskTags, buildTaskForest, flattenVisibleTasks, formatRelative, resolveIndent, resolveNudge, resolveOutdent, resolveReparent, resolveTaskPlacement, statusIcon, statusTextStyle, subtreeIds, taskGroupNodeId };
|
|
739
2191
|
|
|
740
2192
|
//# sourceMappingURL=index.mjs.map
|