@nextclaw/ui 0.24.0-beta.0 → 0.24.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/assets/api-BqVIq0lF.js +13 -0
  3. package/dist/assets/{appearance-settings-page-CPoFZLw8.js → appearance-settings-page-CTWn1eAT.js} +1 -1
  4. package/dist/assets/{book-open-jcUt0rr9.js → book-open-o2x7OLiO.js} +1 -1
  5. package/dist/assets/{channels-list-page-anMJAkfK.js → channels-list-page-Bzn-pzyJ.js} +1 -1
  6. package/dist/assets/{config-split-page-AcuFnbIC.js → config-split-page-CnRbXXlh.js} +1 -1
  7. package/dist/assets/{confirm-dialog-B-atCfC5.js → confirm-dialog-B_Hbcra2.js} +1 -1
  8. package/dist/assets/{createLucideIcon-Blz0FzrV.js → createLucideIcon-rwwpqyks.js} +1 -1
  9. package/dist/assets/{desktop-capabilities-page-8lKF_Ob-.js → desktop-capabilities-page-C2ayYQTr.js} +1 -1
  10. package/dist/assets/{desktop-update-config-DABXDpbj.js → desktop-update-config-C13czpyL.js} +1 -1
  11. package/dist/assets/{dist-eH-_j8yU.js → dist-BsF3hfOi.js} +1 -1
  12. package/dist/assets/{dist-D8kcEpTf.js → dist-aNN3iQxV.js} +1 -1
  13. package/dist/assets/{doc-browser-DzXRehlW.js → doc-browser-D8dIOo9E.js} +1 -1
  14. package/dist/assets/doc-browser-DcuOOtpP.js +1 -0
  15. package/dist/assets/{doc-browser-context-BlbUcg2J.js → doc-browser-context-Dhsit2Ma.js} +1 -1
  16. package/dist/assets/{extensions-mquwAJ2Z.js → extensions-CD6xvKF_.js} +1 -1
  17. package/dist/assets/{form-actions-CaZDGEmB.js → form-actions-CC4Wz_lL.js} +1 -1
  18. package/dist/assets/index-BTsbdtv6.js +109 -0
  19. package/dist/assets/index-Bcfy2kTh.css +1 -0
  20. package/dist/assets/loader-circle-B6aFbB-8.js +1 -0
  21. package/dist/assets/mcp-marketplace-page-BtUAypA7.js +1 -0
  22. package/dist/assets/{mcp-marketplace-page-gFtgyueL.js → mcp-marketplace-page-r2MmD9xO.js} +1 -1
  23. package/dist/assets/{model-config-page-hFBpeRAO.js → model-config-page-DboxoT-I.js} +1 -1
  24. package/dist/assets/{navigation-link-Hb5Z0UxZ.js → navigation-link-C_6oj3zz.js} +1 -1
  25. package/dist/assets/plus-DRsq9Kvs.js +1 -0
  26. package/dist/assets/{privacy-settings-page-C18zo3ox.js → privacy-settings-page-C2sDBqhb.js} +1 -1
  27. package/dist/assets/projects-BcPcx-uD.js +1 -0
  28. package/dist/assets/{providers-config-page-YU0-AOXJ.js → providers-config-page-CmUFl9RF.js} +1 -1
  29. package/dist/assets/react-BjO9I4WZ.js +8 -0
  30. package/dist/assets/{refresh-cw-ZHKfE3yx.js → refresh-cw-DsT2kvXi.js} +1 -1
  31. package/dist/assets/remote-Ci6x91ES.js +1 -0
  32. package/dist/assets/{rotate-cw-CLhOXGwE.js → rotate-cw-BCAs6c3R.js} +1 -1
  33. package/dist/assets/{runtime-config-page-Dmy1A_0Y.js → runtime-config-page-cQKNeAjL.js} +1 -1
  34. package/dist/assets/{save-CWuuZs77.js → save-CadRMTMQ.js} +1 -1
  35. package/dist/assets/{search-DcmsKAn2.js → search-B6tB4fzB.js} +1 -1
  36. package/dist/assets/{search-config-page-CxBacx6W.js → search-config-page-C8lOIkxn.js} +1 -1
  37. package/dist/assets/{secrets-config-page-B4py8-KR.js → secrets-config-page-8clzAag2.js} +1 -1
  38. package/dist/assets/security-config-DPbM6IVT.js +1 -0
  39. package/dist/assets/{select-BEr0Iy_f.js → select-CO9S_C2C.js} +1 -1
  40. package/dist/assets/skeleton-IHILNhIW.js +1 -0
  41. package/dist/assets/{tag-chip-ByqDkn6g.js → tag-chip-BZKk0UkK.js} +1 -1
  42. package/dist/assets/x-BuvHIb1d.js +1 -0
  43. package/dist/index.html +21 -21
  44. package/package.json +6 -6
  45. package/src/features/apps/components/__tests__/app-packages-panel.test.tsx +362 -241
  46. package/src/features/apps/components/app-document-access-section.tsx +187 -0
  47. package/src/features/apps/components/app-package-card.tsx +150 -275
  48. package/src/features/apps/hooks/use-app-packages.ts +91 -35
  49. package/src/features/chat/index.ts +5 -1
  50. package/src/features/projects/AGENTS.md +3 -2
  51. package/src/features/projects/components/project-overview.tsx +70 -139
  52. package/src/features/projects/components/work/project-work-item-drawer.test.tsx +65 -0
  53. package/src/features/projects/components/work/project-work-item-drawer.tsx +367 -0
  54. package/src/features/projects/components/work/project-work-items.test.tsx +98 -0
  55. package/src/features/projects/components/work/project-work-items.tsx +199 -0
  56. package/src/features/projects/components/work/project-work-state-settings.tsx +285 -0
  57. package/src/features/projects/hooks/use-project-observation.ts +56 -26
  58. package/src/features/projects/hooks/use-project-work.test.tsx +58 -0
  59. package/src/features/projects/hooks/use-project-work.ts +215 -0
  60. package/src/features/projects/pages/project-home-page.tsx +119 -138
  61. package/src/features/projects/pages/projects-page.test.tsx +79 -308
  62. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +16 -1
  63. package/src/shared/lib/i18n/locales/en-US/projects.json +60 -1
  64. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +16 -1
  65. package/src/shared/lib/i18n/locales/zh-CN/projects.json +60 -1
  66. package/dist/assets/api-CoqPRKmF.js +0 -13
  67. package/dist/assets/doc-browser-DJEMr7Zk.js +0 -1
  68. package/dist/assets/index-DyR5iR0O.css +0 -1
  69. package/dist/assets/index-XxXj8WDS.js +0 -109
  70. package/dist/assets/loader-circle-0qPOOYQ5.js +0 -1
  71. package/dist/assets/mcp-marketplace-page-uanamJ6L.js +0 -1
  72. package/dist/assets/plus-CYOvNvcV.js +0 -1
  73. package/dist/assets/projects-C8Gm9RS8.js +0 -1
  74. package/dist/assets/react-DF-kP7iN.js +0 -8
  75. package/dist/assets/remote-B_utkBra.js +0 -1
  76. package/dist/assets/security-config-CTP4IQWw.js +0 -1
  77. package/dist/assets/skeleton-B2NHluWf.js +0 -1
  78. package/dist/assets/x-CENkFyK5.js +0 -1
  79. package/src/features/projects/components/project-work-items.test.tsx +0 -78
  80. package/src/features/projects/components/project-work-items.tsx +0 -272
@@ -0,0 +1,367 @@
1
+ import { useState } from "react";
2
+ import type {
3
+ ProjectWorkActivity,
4
+ ProjectWorkItemDetail,
5
+ ProjectWorkState,
6
+ } from "@nextclaw/client-sdk";
7
+ import { ExternalLink, Link2, Trash2, Unlink } from "lucide-react";
8
+ import { toast } from "sonner";
9
+ import { Button } from "@/shared/components/ui/button";
10
+ import { ConfirmDialog } from "@/shared/components/ui/confirm-dialog";
11
+ import { Input } from "@/shared/components/ui/input";
12
+ import {
13
+ Select,
14
+ SelectContent,
15
+ SelectItem,
16
+ SelectTrigger,
17
+ SelectValue,
18
+ } from "@/shared/components/ui/select";
19
+ import {
20
+ Sheet,
21
+ SheetContent,
22
+ SheetDescription,
23
+ SheetFooter,
24
+ SheetHeader,
25
+ SheetTitle,
26
+ } from "@/shared/components/ui/overlays/sheet";
27
+ import { Textarea } from "@/shared/components/ui/textarea";
28
+ import { t } from "@/shared/lib/i18n";
29
+ import {
30
+ useProjectWork,
31
+ useProjectWorkActions,
32
+ useProjectWorkActivity,
33
+ useProjectWorkItem,
34
+ } from "@/features/projects/hooks/use-project-work";
35
+
36
+ export function ProjectWorkItemDrawer({
37
+ onOpenArtifact,
38
+ onOpenChange,
39
+ projectId,
40
+ projectRoot,
41
+ workItemId,
42
+ }: {
43
+ onOpenArtifact: (path: string, label: string) => void;
44
+ onOpenChange: (open: boolean) => void;
45
+ projectId: string;
46
+ projectRoot: string;
47
+ workItemId: string | null;
48
+ }) {
49
+ const item = useProjectWorkItem(projectId, workItemId);
50
+ const activity = useProjectWorkActivity(projectId, workItemId);
51
+ const work = useProjectWork(projectId, true);
52
+ return (
53
+ <Sheet open={Boolean(workItemId)} onOpenChange={onOpenChange}>
54
+ <SheetContent
55
+ side="right"
56
+ closeLabel={t("projectsCloseDetails")}
57
+ className="flex h-full w-full flex-col overflow-hidden p-0 sm:max-w-[680px]"
58
+ >
59
+ {item.isLoading ? (
60
+ <div className="p-6 text-sm text-muted-foreground">
61
+ {t("projectsLoading")}
62
+ </div>
63
+ ) : null}
64
+ {item.isError ? (
65
+ <div className="p-6 text-sm text-destructive">
66
+ {t("projectsWorkItemLoadFailed")}
67
+ </div>
68
+ ) : null}
69
+ {item.data ? (
70
+ <ProjectWorkItemEditor
71
+ key={`${item.data.id}:${item.data.version}`}
72
+ item={item.data}
73
+ states={work.data?.states ?? []}
74
+ activities={activity.data?.activities ?? []}
75
+ onOpenArtifact={(path, label) =>
76
+ onOpenArtifact(joinProjectPath(projectRoot, path), label)
77
+ }
78
+ onClose={() => onOpenChange(false)}
79
+ />
80
+ ) : null}
81
+ </SheetContent>
82
+ </Sheet>
83
+ );
84
+ }
85
+
86
+ function ProjectWorkItemEditor({
87
+ activities,
88
+ item,
89
+ onClose,
90
+ onOpenArtifact,
91
+ states,
92
+ }: {
93
+ activities: ProjectWorkActivity[];
94
+ item: ProjectWorkItemDetail;
95
+ onClose: () => void;
96
+ onOpenArtifact: (path: string, label: string) => void;
97
+ states: ProjectWorkState[];
98
+ }) {
99
+ const actions = useProjectWorkActions(item.projectId);
100
+ const [title, setTitle] = useState(item.title);
101
+ const [description, setDescription] = useState(item.description);
102
+ const [stateId, setStateId] = useState(item.stateId);
103
+ const [attention, setAttention] = useState(item.attention);
104
+ const [artifactPath, setArtifactPath] = useState("");
105
+ const [artifactLabel, setArtifactLabel] = useState("");
106
+ const [confirmDelete, setConfirmDelete] = useState(false);
107
+ const runAction = async (
108
+ action: () => Promise<unknown>,
109
+ onSuccess?: () => void,
110
+ ) => {
111
+ try {
112
+ await action();
113
+ onSuccess?.();
114
+ } catch (error) {
115
+ toast.error(
116
+ error instanceof Error ? error.message : t("projectsWorkActionFailed"),
117
+ );
118
+ }
119
+ };
120
+ const save = async () => {
121
+ try {
122
+ await actions.updateItem.mutateAsync({
123
+ workItemId: item.id,
124
+ patch: {
125
+ title,
126
+ description,
127
+ stateId,
128
+ attention,
129
+ expectedVersion: item.version,
130
+ },
131
+ });
132
+ toast.success(t("projectsWorkItemSaved"));
133
+ } catch (error) {
134
+ toast.error(
135
+ error instanceof Error
136
+ ? error.message
137
+ : t("projectsWorkItemSaveFailed"),
138
+ );
139
+ }
140
+ };
141
+ const linkArtifact = async () => {
142
+ if (!artifactPath.trim()) return;
143
+ try {
144
+ await actions.linkArtifact.mutateAsync({
145
+ workItemId: item.id,
146
+ path: artifactPath.trim(),
147
+ ...(artifactLabel.trim() ? { label: artifactLabel.trim() } : {}),
148
+ });
149
+ setArtifactPath("");
150
+ setArtifactLabel("");
151
+ } catch (error) {
152
+ toast.error(
153
+ error instanceof Error
154
+ ? error.message
155
+ : t("projectsWorkArtifactLinkFailed"),
156
+ );
157
+ }
158
+ };
159
+ return (
160
+ <>
161
+ <SheetHeader className="shrink-0 border-b border-border/60 px-5 pb-5 pt-6 pr-14 sm:px-7 sm:pt-7">
162
+ <SheetTitle>{t("projectsWorkDetails")}</SheetTitle>
163
+ <SheetDescription>
164
+ {item.id} · {t("projectsWorkVersion")} {item.version}
165
+ </SheetDescription>
166
+ </SheetHeader>
167
+ <div className="min-h-0 flex-1 space-y-6 overflow-y-auto px-5 py-6 sm:px-7">
168
+ <section className="space-y-3">
169
+ <label className="block text-xs font-medium text-muted-foreground">
170
+ {t("projectsWorkTitle")}
171
+ </label>
172
+ <Input
173
+ value={title}
174
+ onChange={(event) => setTitle(event.target.value)}
175
+ />
176
+ <label className="block text-xs font-medium text-muted-foreground">
177
+ {t("projectsWorkDescription")}
178
+ </label>
179
+ <Textarea
180
+ className="min-h-28"
181
+ value={description}
182
+ onChange={(event) => setDescription(event.target.value)}
183
+ />
184
+ <div className="grid gap-3 sm:grid-cols-2">
185
+ <div>
186
+ <label className="mb-1 block text-xs font-medium text-muted-foreground">
187
+ {t("projectsStatus")}
188
+ </label>
189
+ <Select value={stateId} onValueChange={setStateId}>
190
+ <SelectTrigger>
191
+ <SelectValue />
192
+ </SelectTrigger>
193
+ <SelectContent>
194
+ {states.map((state) => (
195
+ <SelectItem key={state.id} value={state.id}>
196
+ {state.name}
197
+ </SelectItem>
198
+ ))}
199
+ </SelectContent>
200
+ </Select>
201
+ </div>
202
+ <div>
203
+ <label className="mb-1 block text-xs font-medium text-muted-foreground">
204
+ {t("projectsWorkAttention")}
205
+ </label>
206
+ <Select
207
+ value={attention}
208
+ onValueChange={(value) =>
209
+ setAttention(value as typeof attention)
210
+ }
211
+ >
212
+ <SelectTrigger>
213
+ <SelectValue />
214
+ </SelectTrigger>
215
+ <SelectContent>
216
+ <SelectItem value="none">
217
+ {t("projectsWorkAttention_none")}
218
+ </SelectItem>
219
+ <SelectItem value="blocked">
220
+ {t("projectsWorkAttention_blocked")}
221
+ </SelectItem>
222
+ <SelectItem value="awaiting-user">
223
+ {t("projectsWorkAttention_awaiting-user")}
224
+ </SelectItem>
225
+ </SelectContent>
226
+ </Select>
227
+ </div>
228
+ </div>
229
+ </section>
230
+ <section>
231
+ <h3 className="mb-3 text-sm font-semibold">
232
+ {t("projectsWorkArtifacts")}
233
+ </h3>
234
+ <div className="space-y-2">
235
+ {item.artifacts.map((artifact) => (
236
+ <div
237
+ key={artifact.id}
238
+ className="flex items-center gap-2 rounded-xl border border-border/60 p-2"
239
+ >
240
+ <button
241
+ type="button"
242
+ className="min-w-0 flex-1 rounded-lg px-2 py-1 text-left hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
243
+ onClick={() =>
244
+ onOpenArtifact(
245
+ artifact.path,
246
+ artifact.label ?? artifact.path,
247
+ )
248
+ }
249
+ >
250
+ <span className="flex items-center gap-2 truncate text-sm font-medium">
251
+ <ExternalLink className="h-3.5 w-3.5 shrink-0" />
252
+ {artifact.label ?? artifact.path}
253
+ </span>
254
+ <span className="block truncate text-xs text-muted-foreground">
255
+ {artifact.path}
256
+ </span>
257
+ </button>
258
+ <Button
259
+ size="icon"
260
+ variant="ghost"
261
+ aria-label={t("projectsWorkUnlinkArtifact")}
262
+ onClick={() =>
263
+ void runAction(() =>
264
+ actions.unlinkArtifact.mutateAsync({
265
+ workItemId: item.id,
266
+ artifactLinkId: artifact.id,
267
+ }),
268
+ )
269
+ }
270
+ >
271
+ <Unlink className="h-4 w-4" />
272
+ </Button>
273
+ </div>
274
+ ))}
275
+ {!item.artifacts.length ? (
276
+ <p className="text-sm text-muted-foreground">
277
+ {t("projectsWorkNoArtifacts")}
278
+ </p>
279
+ ) : null}
280
+ </div>
281
+ <div className="mt-3 grid gap-2 sm:grid-cols-[1fr_12rem_auto]">
282
+ <Input
283
+ value={artifactPath}
284
+ onChange={(event) => setArtifactPath(event.target.value)}
285
+ placeholder={t("projectsWorkArtifactPath")}
286
+ />
287
+ <Input
288
+ value={artifactLabel}
289
+ onChange={(event) => setArtifactLabel(event.target.value)}
290
+ placeholder={t("projectsWorkArtifactLabel")}
291
+ />
292
+ <Button
293
+ variant="outline"
294
+ onClick={() => void linkArtifact()}
295
+ disabled={!artifactPath.trim() || actions.linkArtifact.isPending}
296
+ >
297
+ <Link2 className="mr-2 h-4 w-4" />
298
+ {t("projectsWorkLinkArtifact")}
299
+ </Button>
300
+ </div>
301
+ </section>
302
+ <section>
303
+ <h3 className="mb-3 text-sm font-semibold">
304
+ {t("projectsActivity")}
305
+ </h3>
306
+ <ol className="space-y-2">
307
+ {activities.map((entry) => (
308
+ <li
309
+ key={entry.id}
310
+ className="rounded-xl bg-muted/35 px-3 py-2 text-sm"
311
+ >
312
+ <span className="font-medium">
313
+ {t(`projectsWorkActivity_${entry.type}`)}
314
+ </span>
315
+ <span className="ml-2 text-xs text-muted-foreground">
316
+ {entry.actor.kind} ·{" "}
317
+ {new Date(entry.createdAt).toLocaleString()}
318
+ </span>
319
+ </li>
320
+ ))}
321
+ </ol>
322
+ </section>
323
+ </div>
324
+ <SheetFooter className="shrink-0 border-t border-border/60 bg-background px-5 py-4 sm:px-7">
325
+ {item.deletedAt ? (
326
+ <Button
327
+ variant="outline"
328
+ onClick={() =>
329
+ void runAction(() => actions.restoreItem.mutateAsync(item.id))
330
+ }
331
+ >
332
+ {t("projectsRestore")}
333
+ </Button>
334
+ ) : (
335
+ <Button variant="destructive" onClick={() => setConfirmDelete(true)}>
336
+ <Trash2 className="mr-2 h-4 w-4" />
337
+ {t("projectsDelete")}
338
+ </Button>
339
+ )}
340
+ <Button
341
+ onClick={() => void save()}
342
+ disabled={!title.trim() || actions.updateItem.isPending}
343
+ >
344
+ {t("save")}
345
+ </Button>
346
+ </SheetFooter>
347
+ <ConfirmDialog
348
+ open={confirmDelete}
349
+ onOpenChange={setConfirmDelete}
350
+ title={t("projectsDeleteWorkItem")}
351
+ description={t("projectsDeleteWorkItemDescription")}
352
+ confirmLabel={t("projectsDelete")}
353
+ variant="destructive"
354
+ onCancel={() => setConfirmDelete(false)}
355
+ onConfirm={() =>
356
+ void runAction(() => actions.deleteItem.mutateAsync(item.id), onClose)
357
+ }
358
+ />
359
+ </>
360
+ );
361
+ }
362
+
363
+ function joinProjectPath(rootPath: string, relativePath: string): string {
364
+ const separator =
365
+ rootPath.endsWith("/") || rootPath.endsWith("\\") ? "" : "/";
366
+ return `${rootPath}${separator}${relativePath}`;
367
+ }
@@ -0,0 +1,98 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { ProjectWorkItems } from "./project-work-items";
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ createItem: vi.fn(),
7
+ useProjectWork: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("@/features/projects/hooks/use-project-work", () => ({
11
+ sortProjectWorkStates: (states: unknown[]) => states,
12
+ useProjectWork: mocks.useProjectWork,
13
+ useProjectWorkActions: () => ({
14
+ createItem: { mutateAsync: mocks.createItem, isPending: false },
15
+ createState: { mutateAsync: vi.fn(), isPending: false },
16
+ updateState: { mutateAsync: vi.fn(), isPending: false },
17
+ deleteState: { mutateAsync: vi.fn(), isPending: false },
18
+ }),
19
+ }));
20
+
21
+ const state = {
22
+ id: "planned",
23
+ projectId: "project-1",
24
+ name: "Planned",
25
+ category: "unstarted",
26
+ position: 0,
27
+ isDefault: true,
28
+ createdAt: "2026-09-01T00:00:00.000Z",
29
+ updatedAt: "2026-09-01T00:00:00.000Z",
30
+ };
31
+ const item = {
32
+ id: "work-1",
33
+ projectId: "project-1",
34
+ title: "Clickable work item",
35
+ description: "",
36
+ stateId: state.id,
37
+ state,
38
+ attention: "none",
39
+ version: 1,
40
+ createdAt: "2026-09-01T00:00:00.000Z",
41
+ updatedAt: "2026-09-01T00:00:00.000Z",
42
+ deletedAt: null,
43
+ artifacts: [],
44
+ };
45
+
46
+ describe("ProjectWorkItems", () => {
47
+ it("opens the same work item detail from list and board cards", () => {
48
+ mocks.useProjectWork.mockReturnValue({
49
+ isLoading: false,
50
+ isError: false,
51
+ data: { items: [item], states: [state], total: 1 },
52
+ });
53
+ const onOpenWorkItem = vi.fn();
54
+ render(
55
+ <ProjectWorkItems
56
+ projectId="project-1"
57
+ onOpenWorkItem={onOpenWorkItem}
58
+ />,
59
+ );
60
+
61
+ fireEvent.click(
62
+ screen.getByRole("button", { name: /Clickable work item/ }),
63
+ );
64
+ fireEvent.click(screen.getByRole("button", { name: /Board|看板/ }));
65
+ fireEvent.click(
66
+ screen.getByRole("button", { name: /Clickable work item/ }),
67
+ );
68
+
69
+ expect(onOpenWorkItem).toHaveBeenNthCalledWith(1, "work-1");
70
+ expect(onOpenWorkItem).toHaveBeenNthCalledWith(2, "work-1");
71
+ });
72
+
73
+ it("creates a work item and immediately opens its drawer", async () => {
74
+ mocks.useProjectWork.mockReturnValue({
75
+ isLoading: false,
76
+ isError: false,
77
+ data: { items: [], states: [state], total: 0 },
78
+ });
79
+ mocks.createItem.mockResolvedValue(item);
80
+ const onOpenWorkItem = vi.fn();
81
+ render(
82
+ <ProjectWorkItems
83
+ projectId="project-1"
84
+ onOpenWorkItem={onOpenWorkItem}
85
+ />,
86
+ );
87
+
88
+ fireEvent.change(
89
+ screen.getByPlaceholderText(/Add a work item|添加工作项/),
90
+ { target: { value: "Clickable work item" } },
91
+ );
92
+ fireEvent.click(screen.getByRole("button", { name: /Create|创建/ }));
93
+
94
+ await vi.waitFor(() =>
95
+ expect(onOpenWorkItem).toHaveBeenCalledWith("work-1"),
96
+ );
97
+ });
98
+ });
@@ -0,0 +1,199 @@
1
+ import { useState } from "react";
2
+ import type { ProjectWorkItemDetail } from "@nextclaw/client-sdk";
3
+ import { LayoutGrid, List, Plus, Settings2 } from "lucide-react";
4
+ import { toast } from "sonner";
5
+ import { Button } from "@/shared/components/ui/button";
6
+ import { Input } from "@/shared/components/ui/input";
7
+ import { t } from "@/shared/lib/i18n";
8
+ import {
9
+ useProjectWork,
10
+ useProjectWorkActions,
11
+ } from "@/features/projects/hooks/use-project-work";
12
+ import { ProjectWorkStateSettings } from "./project-work-state-settings";
13
+
14
+ function WorkItemButton({
15
+ item,
16
+ onOpen,
17
+ }: {
18
+ item: ProjectWorkItemDetail;
19
+ onOpen: () => void;
20
+ }) {
21
+ return (
22
+ <button
23
+ type="button"
24
+ className="w-full rounded-xl border border-border/50 bg-card p-3 text-left text-sm shadow-sm transition-colors hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
25
+ onClick={onOpen}
26
+ >
27
+ <span className="block font-medium">{item.title}</span>
28
+ <span className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
29
+ <span>{item.state.name}</span>
30
+ {item.attention !== "none" ? (
31
+ <span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-amber-700 dark:text-amber-300">
32
+ {t(`projectsWorkAttention_${item.attention}`)}
33
+ </span>
34
+ ) : null}
35
+ {item.deletedAt ? <span>{t("projectsDeleted")}</span> : null}
36
+ </span>
37
+ </button>
38
+ );
39
+ }
40
+
41
+ export function ProjectWorkItems({
42
+ onOpenWorkItem,
43
+ projectId,
44
+ }: {
45
+ onOpenWorkItem: (workItemId: string) => void;
46
+ projectId: string;
47
+ }) {
48
+ const [view, setView] = useState<"list" | "board">("list");
49
+ const [includeDeleted, setIncludeDeleted] = useState(false);
50
+ const [newTitle, setNewTitle] = useState("");
51
+ const [settingsOpen, setSettingsOpen] = useState(false);
52
+ const work = useProjectWork(projectId, includeDeleted);
53
+ const actions = useProjectWorkActions(projectId);
54
+ const create = async () => {
55
+ if (!newTitle.trim()) return;
56
+ try {
57
+ const item = await actions.createItem.mutateAsync({
58
+ title: newTitle.trim(),
59
+ });
60
+ setNewTitle("");
61
+ onOpenWorkItem(item.id);
62
+ } catch (error) {
63
+ toast.error(
64
+ error instanceof Error
65
+ ? error.message
66
+ : t("projectsWorkItemCreateFailed"),
67
+ );
68
+ }
69
+ };
70
+ if (work.isLoading)
71
+ return (
72
+ <div className="rounded-xl border border-border/60 p-5 text-sm text-muted-foreground">
73
+ {t("projectsLoading")}
74
+ </div>
75
+ );
76
+ if (work.isError)
77
+ return (
78
+ <div className="rounded-xl border border-destructive/40 p-5 text-sm text-destructive">
79
+ {t("projectsLoadFailed")}
80
+ </div>
81
+ );
82
+ const data = work.data!;
83
+ return (
84
+ <section aria-label={t("projectsWork")} className="min-w-0 space-y-4">
85
+ <div className="flex flex-wrap items-center gap-2">
86
+ <div className="flex min-w-[16rem] flex-1 gap-2">
87
+ <Input
88
+ value={newTitle}
89
+ onChange={(event) => setNewTitle(event.target.value)}
90
+ onKeyDown={(event) => {
91
+ if (event.key === "Enter") void create();
92
+ }}
93
+ placeholder={t("projectsWorkNewItem")}
94
+ />
95
+ <Button
96
+ onClick={() => void create()}
97
+ disabled={!newTitle.trim() || actions.createItem.isPending}
98
+ >
99
+ <Plus className="mr-2 h-4 w-4" />
100
+ {t("projectsCreate")}
101
+ </Button>
102
+ </div>
103
+ <div
104
+ className="flex items-center gap-1 rounded-xl bg-muted/40 p-1"
105
+ role="group"
106
+ aria-label={t("projectsWorkViews")}
107
+ >
108
+ <Button
109
+ size="sm"
110
+ variant={view === "list" ? "secondary" : "ghost"}
111
+ aria-pressed={view === "list"}
112
+ onClick={() => setView("list")}
113
+ >
114
+ <List className="mr-2 h-4 w-4" />
115
+ {t("projectsWorkList")}
116
+ </Button>
117
+ <Button
118
+ size="sm"
119
+ variant={view === "board" ? "secondary" : "ghost"}
120
+ aria-pressed={view === "board"}
121
+ onClick={() => setView("board")}
122
+ >
123
+ <LayoutGrid className="mr-2 h-4 w-4" />
124
+ {t("projectsWorkBoard")}
125
+ </Button>
126
+ </div>
127
+ <Button
128
+ variant="outline"
129
+ size="sm"
130
+ onClick={() => setSettingsOpen(true)}
131
+ >
132
+ <Settings2 className="mr-2 h-4 w-4" />
133
+ {t("projectsWorkStateSettings")}
134
+ </Button>
135
+ </div>
136
+ <label className="flex w-fit items-center gap-2 text-xs text-muted-foreground">
137
+ <input
138
+ type="checkbox"
139
+ checked={includeDeleted}
140
+ onChange={(event) => setIncludeDeleted(event.target.checked)}
141
+ />
142
+ {t("projectsWorkShowDeleted")}
143
+ </label>
144
+ {!data.items.length ? (
145
+ <div className="rounded-xl border border-dashed border-border/60 p-8 text-center text-sm text-muted-foreground">
146
+ {t("projectsWorkEmpty")}
147
+ </div>
148
+ ) : null}
149
+ {view === "list" && data.items.length ? (
150
+ <div className="space-y-2">
151
+ {data.items.map((item) => (
152
+ <WorkItemButton
153
+ key={item.id}
154
+ item={item}
155
+ onOpen={() => onOpenWorkItem(item.id)}
156
+ />
157
+ ))}
158
+ </div>
159
+ ) : null}
160
+ {view === "board" && data.items.length ? (
161
+ <div className="grid min-w-0 gap-3 md:grid-cols-2 xl:grid-cols-3">
162
+ {data.states.map((state) => {
163
+ const items = data.items.filter(
164
+ (item) => item.stateId === state.id,
165
+ );
166
+ return (
167
+ <section
168
+ key={state.id}
169
+ className="min-w-0 rounded-xl bg-muted/35 p-3"
170
+ >
171
+ <div className="mb-3 flex items-center justify-between gap-2">
172
+ <h3 className="text-sm font-semibold">{state.name}</h3>
173
+ <span className="text-xs text-muted-foreground">
174
+ {items.length}
175
+ </span>
176
+ </div>
177
+ <div className="space-y-2">
178
+ {items.map((item) => (
179
+ <WorkItemButton
180
+ key={item.id}
181
+ item={item}
182
+ onOpen={() => onOpenWorkItem(item.id)}
183
+ />
184
+ ))}
185
+ </div>
186
+ </section>
187
+ );
188
+ })}
189
+ </div>
190
+ ) : null}
191
+ <ProjectWorkStateSettings
192
+ open={settingsOpen}
193
+ onOpenChange={setSettingsOpen}
194
+ projectId={projectId}
195
+ states={data.states}
196
+ />
197
+ </section>
198
+ );
199
+ }