@xenide-io/the-old-ui-theme 0.7.0 → 0.9.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 (109) hide show
  1. package/README.md +9 -10
  2. package/dist/{Chip-Cq3AiAji.d.mts → Chip-BzuNgJIa.d.mts} +81 -23
  3. package/dist/{Chip-Cq3AiAji.d.ts → Chip-BzuNgJIa.d.ts} +81 -23
  4. package/dist/{ai-message-blocknote-WABTHFY7.mjs → ai-message-blocknote-D4Z6DUCJ.mjs} +9 -3
  5. package/dist/chunk-2EO5VCXP.mjs +3912 -0
  6. package/dist/chunk-APKRZQRO.mjs +251 -0
  7. package/dist/chunk-BS7BZI5W.mjs +19 -0
  8. package/dist/chunk-IULORI3A.mjs +8 -0
  9. package/dist/index.d.mts +3 -3
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.js +997 -576
  12. package/dist/index.mjs +49 -45
  13. package/dist/suite.d.mts +157 -55
  14. package/dist/suite.d.ts +157 -55
  15. package/dist/suite.js +4249 -801
  16. package/dist/suite.mjs +2578 -594
  17. package/dist/theme-boot-P-oqKVZF.d.mts +74 -0
  18. package/dist/theme-boot-P-oqKVZF.d.ts +74 -0
  19. package/dist/theme-boot.d.mts +2 -0
  20. package/dist/theme-boot.d.ts +2 -0
  21. package/dist/theme-boot.js +44 -0
  22. package/dist/theme-boot.mjs +8 -0
  23. package/dist/ui.d.mts +5 -29
  24. package/dist/ui.d.ts +5 -29
  25. package/dist/ui.js +647 -352
  26. package/dist/ui.mjs +10 -12
  27. package/docs/README.md +34 -0
  28. package/docs/ai-quick-reference.md +56 -0
  29. package/docs/component-library-improvement-plan.md +751 -0
  30. package/docs/component-reference.md +81 -0
  31. package/docs/components.md +48 -0
  32. package/docs/demos.md +19 -0
  33. package/docs/installation.md +117 -0
  34. package/docs/suite-audit.md +34 -0
  35. package/docs/theme-token-reference.md +67 -0
  36. package/docs/themes.md +164 -0
  37. package/package.json +22 -21
  38. package/public/fonts/OpenRunde-Bold.woff2 +0 -0
  39. package/public/fonts/OpenRunde-Medium.woff2 +0 -0
  40. package/public/fonts/OpenRunde-Regular.woff2 +0 -0
  41. package/public/fonts/OpenRunde-Semibold.woff2 +0 -0
  42. package/scripts/package-smoke.mjs +43 -0
  43. package/src/app/globals.css +2 -1
  44. package/src/components/ui/Accordion.tsx +20 -6
  45. package/src/components/ui/Avatar.tsx +5 -3
  46. package/src/components/ui/Calendar.tsx +187 -63
  47. package/src/components/ui/Card.test.tsx +38 -0
  48. package/src/components/ui/Card.tsx +77 -14
  49. package/src/components/ui/CollapsibleSection.tsx +12 -4
  50. package/src/components/ui/CommandPalette.test.tsx +91 -4
  51. package/src/components/ui/CommandPalette.tsx +138 -128
  52. package/src/components/ui/EmptyState.tsx +2 -2
  53. package/src/components/ui/FormField.tsx +1 -1
  54. package/src/components/ui/Input.tsx +42 -18
  55. package/src/components/ui/Kbd.tsx +18 -3
  56. package/src/components/ui/Modal.tsx +3 -1
  57. package/src/components/ui/Progress.tsx +14 -5
  58. package/src/components/ui/SegmentedControl.tsx +2 -2
  59. package/src/components/ui/SettingsLayout.tsx +1 -1
  60. package/src/components/ui/Stat.tsx +3 -2
  61. package/src/components/ui/Table.tsx +8 -3
  62. package/src/components/ui/Tooltip.test.tsx +70 -1
  63. package/src/components/ui/Tooltip.tsx +53 -29
  64. package/src/components/ui/Typography.tsx +103 -49
  65. package/src/components/ui/avatar-stat.test.tsx +23 -0
  66. package/src/components/ui/calendar.test.tsx +88 -0
  67. package/src/components/ui/disclosure.test.tsx +39 -0
  68. package/src/components/ui/index.ts +44 -6
  69. package/src/components/ui/progress.test.tsx +15 -0
  70. package/src/components/ui/typography.test.tsx +26 -1
  71. package/src/styles/suite-skin.css +8 -2
  72. package/src/styles/themes.css +184 -68
  73. package/src/suite/components/ai-message-blocknote.tsx +13 -2
  74. package/src/suite/components/ai-panel.tsx +41 -10
  75. package/src/suite/components/app-switcher.test.tsx +21 -6
  76. package/src/suite/components/app-switcher.tsx +7 -11
  77. package/src/suite/components/suite-app-layout.tsx +12 -3
  78. package/src/suite/components/suite-bottom-nav.tsx +0 -1
  79. package/src/suite/components/suite-clients-projects-directory.test.tsx +71 -0
  80. package/src/suite/components/suite-clients-projects-directory.tsx +943 -0
  81. package/src/suite/components/suite-integration-picker.test.tsx +224 -0
  82. package/src/suite/components/suite-integration-picker.tsx +939 -0
  83. package/src/suite/components/suite-integration-rules.test.tsx +157 -0
  84. package/src/suite/components/suite-integration-rules.tsx +471 -0
  85. package/src/suite/components/suite-mobile-drawer.test.tsx +18 -2
  86. package/src/suite/components/suite-mobile-drawer.tsx +65 -98
  87. package/src/suite/components/suite-notification-bell.tsx +29 -4
  88. package/src/suite/components/suite-sidebar.tsx +25 -25
  89. package/src/suite/components/suite-skeleton.tsx +2 -2
  90. package/src/suite/components/suite-user-menu.tsx +9 -8
  91. package/src/suite/components/theme-provider.test.tsx +87 -3
  92. package/src/suite/components/theme-provider.tsx +144 -23
  93. package/src/suite/components/today-ui.tsx +79 -68
  94. package/src/suite/components/workspace-switcher.test.tsx +56 -0
  95. package/src/suite/components/workspace-switcher.tsx +3 -3
  96. package/src/suite/icons/app-accents.ts +12 -12
  97. package/src/suite/icons/glyphs.ts +6 -12
  98. package/src/suite/index.ts +27 -0
  99. package/src/suite/lib/apps.test.ts +9 -1
  100. package/src/suite/lib/apps.ts +14 -8
  101. package/src/suite/lib/cookies.ts +53 -0
  102. package/src/suite/lib/theme-boot.ts +32 -0
  103. package/src/suite/lib/theme-cookie.ts +7 -0
  104. package/src/suite/lib/use-sidebar-width.ts +5 -51
  105. package/dist/chunk-KDR6HI6F.mjs +0 -1075
  106. package/dist/chunk-NOI42JNZ.mjs +0 -151
  107. package/dist/chunk-VXISSXTB.mjs +0 -2564
  108. package/src/suite/lib/use-lock-mobile-page-zoom.test.tsx +0 -65
  109. package/src/suite/lib/use-lock-mobile-page-zoom.ts +0 -51
@@ -0,0 +1,157 @@
1
+ import {
2
+ cleanup,
3
+ render,
4
+ screen,
5
+ waitFor,
6
+ within,
7
+ } from "@testing-library/react";
8
+ import userEvent from "@testing-library/user-event";
9
+ import { afterEach, describe, expect, it, vi } from "vitest";
10
+ import { SuiteIntegrationRules } from "@/suite/components/suite-integration-rules";
11
+
12
+ describe("SuiteIntegrationRules", () => {
13
+ afterEach(cleanup);
14
+
15
+ const rule = {
16
+ id: "rule-1",
17
+ name: "GitHub issues to tasks",
18
+ trigger_event_type: "github.issue_assigned",
19
+ action_type: "create_tides_task",
20
+ action_config: {},
21
+ enabled: true,
22
+ last_fired_at: null,
23
+ };
24
+
25
+ it("lists rules and recent events read-only for members", () => {
26
+ render(
27
+ <SuiteIntegrationRules
28
+ canManage={false}
29
+ projects={[]}
30
+ rules={[rule]}
31
+ events={[
32
+ {
33
+ id: "evt-1",
34
+ provider: "github",
35
+ event_type: "github.issue_assigned",
36
+ processed_at: "2026-09-04T01:00:00Z",
37
+ created_at: "2026-09-04T01:00:00Z",
38
+ },
39
+ ]}
40
+ />,
41
+ );
42
+
43
+ expect(screen.getByText("GitHub issues to tasks")).toBeInTheDocument();
44
+ expect(
45
+ screen.getByText("github.issue_assigned → Create a Tides task"),
46
+ ).toBeInTheDocument();
47
+ expect(
48
+ screen.getByText("github · github.issue_assigned"),
49
+ ).toBeInTheDocument();
50
+ expect(
51
+ screen.queryByRole("button", { name: "New rule" }),
52
+ ).not.toBeInTheDocument();
53
+ });
54
+
55
+ it("creates a rule with the selected action configuration", async () => {
56
+ const user = userEvent.setup();
57
+ const onCreate = vi.fn().mockResolvedValue(undefined);
58
+ const onRefresh = vi.fn();
59
+
60
+ render(
61
+ <SuiteIntegrationRules
62
+ canManage
63
+ projects={[{ id: "proj-1", name: "Atlas" }]}
64
+ rules={[]}
65
+ events={[]}
66
+ onCreate={onCreate}
67
+ onRefresh={onRefresh}
68
+ />,
69
+ );
70
+
71
+ await user.click(screen.getByRole("button", { name: "New rule" }));
72
+ await user.type(screen.getByLabelText("Rule name *"), "Issues to Atlas");
73
+ await user.type(
74
+ screen.getByLabelText(/Trigger event type/),
75
+ "github.issue_assigned",
76
+ );
77
+ await user.click(screen.getByRole("button", { name: "Create rule" }));
78
+
79
+ await waitFor(() =>
80
+ expect(onCreate).toHaveBeenCalledWith({
81
+ name: "Issues to Atlas",
82
+ trigger_event_type: "github.issue_assigned",
83
+ action_type: "create_tides_task",
84
+ action_config: {},
85
+ }),
86
+ );
87
+ expect(onRefresh).toHaveBeenCalled();
88
+ });
89
+
90
+ it("includes the chosen project in the rule configuration", async () => {
91
+ const user = userEvent.setup();
92
+ const onCreate = vi.fn().mockResolvedValue(undefined);
93
+
94
+ render(
95
+ <SuiteIntegrationRules
96
+ canManage
97
+ projects={[{ id: "proj-1", name: "Atlas" }]}
98
+ rules={[]}
99
+ events={[]}
100
+ onCreate={onCreate}
101
+ />,
102
+ );
103
+
104
+ await user.click(screen.getByRole("button", { name: "New rule" }));
105
+ await user.type(screen.getByLabelText("Rule name *"), "Time entries");
106
+ await user.type(
107
+ screen.getByLabelText(/Trigger event type/),
108
+ "googlecalendar.event_ended",
109
+ );
110
+ await user.selectOptions(
111
+ screen.getByLabelText("Action"),
112
+ "draft_time_entry",
113
+ );
114
+ await user.selectOptions(screen.getByLabelText("Project"), "proj-1");
115
+ await user.type(screen.getByLabelText("Minutes (optional)"), "30");
116
+ await user.click(screen.getByRole("button", { name: "Create rule" }));
117
+
118
+ await waitFor(() =>
119
+ expect(onCreate).toHaveBeenCalledWith({
120
+ name: "Time entries",
121
+ trigger_event_type: "googlecalendar.event_ended",
122
+ action_type: "draft_time_entry",
123
+ action_config: { project_id: "proj-1", minutes: "30" },
124
+ }),
125
+ );
126
+ });
127
+
128
+ it("confirms before deleting a rule", async () => {
129
+ const user = userEvent.setup();
130
+ const onDelete = vi.fn().mockResolvedValue(undefined);
131
+
132
+ render(
133
+ <SuiteIntegrationRules
134
+ canManage
135
+ projects={[]}
136
+ rules={[rule]}
137
+ events={[]}
138
+ onDelete={onDelete}
139
+ />,
140
+ );
141
+
142
+ await user.click(
143
+ screen.getByRole("button", { name: "Delete GitHub issues to tasks" }),
144
+ );
145
+ expect(onDelete).not.toHaveBeenCalled();
146
+
147
+ const confirmation = screen.getByRole("dialog", {
148
+ name: "Delete automation rule",
149
+ });
150
+ expect(confirmation).toHaveTextContent("Delete GitHub issues to tasks?");
151
+ await user.click(
152
+ within(confirmation).getByRole("button", { name: "Delete rule" }),
153
+ );
154
+
155
+ await waitFor(() => expect(onDelete).toHaveBeenCalledWith("rule-1"));
156
+ });
157
+ });
@@ -0,0 +1,471 @@
1
+ "use client";
2
+
3
+ import { useState, type FormEvent } from "react";
4
+ import { Plus, Xmark } from "iconoir-react";
5
+ import {
6
+ Alert,
7
+ Badge,
8
+ Button,
9
+ Card,
10
+ Input,
11
+ Modal,
12
+ Select,
13
+ Toggle,
14
+ } from "@/components/ui";
15
+
16
+ export type SuiteIntegrationRuleActionType =
17
+ "create_tides_task" | "draft_time_entry" | "create_suite_notification";
18
+
19
+ export interface SuiteIntegrationRule {
20
+ id: string;
21
+ name: string;
22
+ trigger_event_type: string;
23
+ action_type: string;
24
+ action_config: Record<string, unknown>;
25
+ enabled: boolean;
26
+ last_fired_at: string | null;
27
+ }
28
+
29
+ export interface SuiteIntegrationEvent {
30
+ id: string;
31
+ provider: string;
32
+ event_type: string;
33
+ processed_at: string | null;
34
+ created_at: string;
35
+ }
36
+
37
+ export interface SuiteIntegrationRuleProject {
38
+ id: string;
39
+ name: string;
40
+ }
41
+
42
+ export interface SuiteIntegrationRuleInput {
43
+ name: string;
44
+ trigger_event_type: string;
45
+ action_type: SuiteIntegrationRuleActionType;
46
+ action_config: Record<string, string>;
47
+ }
48
+
49
+ export interface SuiteIntegrationRulesProps {
50
+ canManage: boolean;
51
+ projects: SuiteIntegrationRuleProject[];
52
+ rules: SuiteIntegrationRule[];
53
+ events: SuiteIntegrationEvent[];
54
+ loading?: boolean;
55
+ error?: string | null;
56
+ onCreate?: (input: SuiteIntegrationRuleInput) => Promise<void>;
57
+ onUpdate?: (id: string, patch: { enabled?: boolean }) => Promise<void>;
58
+ onDelete?: (id: string) => Promise<void>;
59
+ onRefresh?: () => void;
60
+ dataTest?: string;
61
+ }
62
+
63
+ const ACTION_LABELS: Record<SuiteIntegrationRuleActionType, string> = {
64
+ create_tides_task: "Create a Tides task",
65
+ draft_time_entry: "Draft a time entry",
66
+ create_suite_notification: "Send a suite notification",
67
+ };
68
+
69
+ function actionLabel(actionType: string) {
70
+ return (
71
+ ACTION_LABELS[actionType as SuiteIntegrationRuleActionType] ?? actionType
72
+ );
73
+ }
74
+
75
+ function formatDateTime(value: string | null) {
76
+ if (!value) return "—";
77
+ return new Date(value).toLocaleString(undefined, {
78
+ dateStyle: "medium",
79
+ timeStyle: "short",
80
+ });
81
+ }
82
+
83
+ export function SuiteIntegrationRules({
84
+ canManage,
85
+ projects,
86
+ rules,
87
+ events,
88
+ loading = false,
89
+ error = null,
90
+ onCreate,
91
+ onUpdate,
92
+ onDelete,
93
+ onRefresh,
94
+ dataTest = "integration-rules",
95
+ }: SuiteIntegrationRulesProps) {
96
+ const [formOpen, setFormOpen] = useState(false);
97
+ const [name, setName] = useState("");
98
+ const [trigger, setTrigger] = useState("");
99
+ const [actionType, setActionType] =
100
+ useState<SuiteIntegrationRuleActionType>("create_tides_task");
101
+ const [projectId, setProjectId] = useState("");
102
+ const [minutes, setMinutes] = useState("");
103
+ const [priority, setPriority] = useState("low");
104
+ const [submitting, setSubmitting] = useState(false);
105
+ const [formError, setFormError] = useState<string | null>(null);
106
+ const [busyId, setBusyId] = useState<string | null>(null);
107
+ const [ruleToDelete, setRuleToDelete] =
108
+ useState<SuiteIntegrationRule | null>(null);
109
+ const [deleteError, setDeleteError] = useState<string | null>(null);
110
+
111
+ const needsProject =
112
+ actionType === "create_tides_task" || actionType === "draft_time_entry";
113
+
114
+ async function submitForm(event: FormEvent<HTMLFormElement>) {
115
+ event.preventDefault();
116
+ if (!onCreate) return;
117
+ setSubmitting(true);
118
+ setFormError(null);
119
+ try {
120
+ const actionConfig: Record<string, string> = {};
121
+ if (projectId) actionConfig.project_id = projectId;
122
+ if (actionType === "draft_time_entry" && minutes)
123
+ actionConfig.minutes = minutes;
124
+ if (actionType === "create_tides_task" && priority !== "low")
125
+ actionConfig.priority = priority;
126
+ await onCreate({
127
+ name: name.trim(),
128
+ trigger_event_type: trigger.trim(),
129
+ action_type: actionType,
130
+ action_config: actionConfig,
131
+ });
132
+ setName("");
133
+ setTrigger("");
134
+ setProjectId("");
135
+ setMinutes("");
136
+ setPriority("low");
137
+ setFormOpen(false);
138
+ onRefresh?.();
139
+ } catch (cause) {
140
+ setFormError(
141
+ cause instanceof Error ? cause.message : "Could not create the rule.",
142
+ );
143
+ } finally {
144
+ setSubmitting(false);
145
+ }
146
+ }
147
+
148
+ async function toggleRule(rule: SuiteIntegrationRule) {
149
+ if (!onUpdate) return;
150
+ setBusyId(rule.id);
151
+ try {
152
+ await onUpdate(rule.id, { enabled: !rule.enabled });
153
+ onRefresh?.();
154
+ } finally {
155
+ setBusyId(null);
156
+ }
157
+ }
158
+
159
+ function requestDelete(rule: SuiteIntegrationRule) {
160
+ setDeleteError(null);
161
+ setRuleToDelete(rule);
162
+ }
163
+
164
+ async function deleteRule() {
165
+ if (!onDelete || !ruleToDelete) return;
166
+ setBusyId(ruleToDelete.id);
167
+ setDeleteError(null);
168
+ try {
169
+ await onDelete(ruleToDelete.id);
170
+ onRefresh?.();
171
+ setRuleToDelete(null);
172
+ } catch (cause) {
173
+ setDeleteError(
174
+ cause instanceof Error ? cause.message : "Could not delete the rule.",
175
+ );
176
+ } finally {
177
+ setBusyId(null);
178
+ }
179
+ }
180
+
181
+ return (
182
+ <section className="space-y-4" data-test={dataTest}>
183
+ <Card
184
+ variant="outlined"
185
+ bodyClassName="p-5"
186
+ data-test={`${dataTest}-card`}
187
+ >
188
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
189
+ <div>
190
+ <h3 className="font-medium text-ph-ink">Automation rules</h3>
191
+ <p className="mt-1 text-sm text-ph-subtle">
192
+ Turn incoming integration events into tasks, time entries, or
193
+ notifications for this workspace.
194
+ </p>
195
+ </div>
196
+ {canManage && onCreate ? (
197
+ <Button
198
+ type="button"
199
+ variant="tertiary"
200
+ size="sm"
201
+ onClick={() => setFormOpen((open) => !open)}
202
+ data-test={`${dataTest}-new`}
203
+ >
204
+ <span className="flex items-center gap-1.5">
205
+ <Plus className="h-4 w-4" aria-hidden />
206
+ New rule
207
+ </span>
208
+ </Button>
209
+ ) : null}
210
+ </div>
211
+
212
+ {error ? (
213
+ <Alert
214
+ status="danger"
215
+ title="Could not load rules"
216
+ description={error}
217
+ />
218
+ ) : null}
219
+
220
+ {loading ? (
221
+ <p className="mt-4 text-sm text-ph-subtle">Loading rules...</p>
222
+ ) : rules.length === 0 ? (
223
+ <div
224
+ className="mt-4 rounded-lg border border-dashed border-ph-border px-4 py-6 text-center"
225
+ data-test={`${dataTest}-empty`}
226
+ >
227
+ <p className="text-sm text-ph-subtle">
228
+ No automation rules yet.
229
+ {canManage ? " Create one to get started." : ""}
230
+ </p>
231
+ </div>
232
+ ) : (
233
+ <ul className="mt-4 space-y-2" data-test={`${dataTest}-list`}>
234
+ {rules.map((rule) => (
235
+ <li
236
+ key={rule.id}
237
+ className="flex items-center justify-between gap-3 rounded-lg border border-ph-border bg-ph-muted px-3 py-3"
238
+ data-test={`${dataTest}-rule-${rule.id}`}
239
+ >
240
+ <div className="min-w-0">
241
+ <p className="truncate text-sm font-medium text-ph-ink">
242
+ {rule.name}
243
+ </p>
244
+ <p className="mt-0.5 truncate text-xs text-ph-subtle">
245
+ {rule.trigger_event_type} → {actionLabel(rule.action_type)}
246
+ </p>
247
+ <p className="mt-0.5 truncate text-xs text-ph-subtle">
248
+ Last fired: {formatDateTime(rule.last_fired_at)}
249
+ </p>
250
+ </div>
251
+ {canManage ? (
252
+ <div className="flex shrink-0 items-center gap-2">
253
+ <Toggle
254
+ checked={rule.enabled}
255
+ disabled={busyId === rule.id}
256
+ onChange={() => void toggleRule(rule)}
257
+ aria-label={`Enable ${rule.name}`}
258
+ />
259
+ <Button
260
+ type="button"
261
+ variant="tertiary"
262
+ size="sm"
263
+ disabled={busyId === rule.id}
264
+ onClick={() => requestDelete(rule)}
265
+ aria-label={`Delete ${rule.name}`}
266
+ >
267
+ <Xmark className="h-4 w-4" aria-hidden />
268
+ </Button>
269
+ </div>
270
+ ) : (
271
+ <Badge
272
+ variant={rule.enabled ? "success" : "neutral"}
273
+ size="sm"
274
+ >
275
+ {rule.enabled ? "Enabled" : "Disabled"}
276
+ </Badge>
277
+ )}
278
+ </li>
279
+ ))}
280
+ </ul>
281
+ )}
282
+
283
+ {canManage && formOpen && onCreate ? (
284
+ <form
285
+ className="mt-4 space-y-3 rounded-lg border border-ph-border bg-ph-surface p-4"
286
+ onSubmit={(event) => void submitForm(event)}
287
+ data-test={`${dataTest}-form`}
288
+ >
289
+ {formError ? (
290
+ <p className="text-sm text-ph-danger" role="alert">
291
+ {formError}
292
+ </p>
293
+ ) : null}
294
+ <Input
295
+ label="Rule name"
296
+ required
297
+ value={name}
298
+ onChange={(event) => setName(event.target.value)}
299
+ placeholder="GitHub issues to tasks"
300
+ data-test={`${dataTest}-name`}
301
+ />
302
+ <Input
303
+ label="Trigger event type"
304
+ required
305
+ value={trigger}
306
+ onChange={(event) => setTrigger(event.target.value)}
307
+ placeholder="github.issue_assigned"
308
+ helperText="Provider event type, for example github.issue_assigned or googlecalendar.event_ended."
309
+ data-test={`${dataTest}-trigger`}
310
+ />
311
+ <Select
312
+ label="Action"
313
+ value={actionType}
314
+ onChange={(event) =>
315
+ setActionType(
316
+ event.target.value as SuiteIntegrationRuleActionType,
317
+ )
318
+ }
319
+ data-test={`${dataTest}-action`}
320
+ >
321
+ {(
322
+ Object.entries(ACTION_LABELS) as [
323
+ SuiteIntegrationRuleActionType,
324
+ string,
325
+ ][]
326
+ ).map(([value, label]) => (
327
+ <option key={value} value={value}>
328
+ {label}
329
+ </option>
330
+ ))}
331
+ </Select>
332
+ {needsProject ? (
333
+ <Select
334
+ label="Project"
335
+ value={projectId}
336
+ onChange={(event) => setProjectId(event.target.value)}
337
+ data-test={`${dataTest}-project`}
338
+ >
339
+ <option value="">Choose a project...</option>
340
+ {projects.map((project) => (
341
+ <option key={project.id} value={project.id}>
342
+ {project.name}
343
+ </option>
344
+ ))}
345
+ </Select>
346
+ ) : null}
347
+ {actionType === "draft_time_entry" ? (
348
+ <Input
349
+ label="Minutes (optional)"
350
+ type="number"
351
+ min="1"
352
+ value={minutes}
353
+ onChange={(event) => setMinutes(event.target.value)}
354
+ data-test={`${dataTest}-minutes`}
355
+ />
356
+ ) : null}
357
+ {actionType === "create_tides_task" ? (
358
+ <Select
359
+ label="Priority"
360
+ value={priority}
361
+ onChange={(event) => setPriority(event.target.value)}
362
+ data-test={`${dataTest}-priority`}
363
+ >
364
+ <option value="low">Low</option>
365
+ <option value="medium">Medium</option>
366
+ <option value="high">High</option>
367
+ <option value="urgent">Urgent</option>
368
+ </Select>
369
+ ) : null}
370
+ <div className="flex justify-end gap-2 border-t border-ph-border pt-3">
371
+ <Button
372
+ type="button"
373
+ variant="tertiary"
374
+ onClick={() => setFormOpen(false)}
375
+ disabled={submitting}
376
+ >
377
+ Cancel
378
+ </Button>
379
+ <Button type="submit" variant="primary" disabled={submitting}>
380
+ {submitting ? "Creating..." : "Create rule"}
381
+ </Button>
382
+ </div>
383
+ </form>
384
+ ) : null}
385
+ </Card>
386
+
387
+ <Modal
388
+ open={ruleToDelete !== null}
389
+ onClose={() => {
390
+ if (busyId !== ruleToDelete?.id) setRuleToDelete(null);
391
+ }}
392
+ title="Delete automation rule"
393
+ description="This action cannot be undone."
394
+ dataTest={`${dataTest}-delete-confirmation`}
395
+ footer={
396
+ <div className="flex justify-end gap-2">
397
+ <Button
398
+ type="button"
399
+ variant="tertiary"
400
+ onClick={() => setRuleToDelete(null)}
401
+ disabled={busyId === ruleToDelete?.id}
402
+ >
403
+ Cancel
404
+ </Button>
405
+ <Button
406
+ type="button"
407
+ variant="danger"
408
+ onClick={() => void deleteRule()}
409
+ disabled={busyId === ruleToDelete?.id}
410
+ data-test={`${dataTest}-delete-confirm`}
411
+ >
412
+ {busyId === ruleToDelete?.id ? "Deleting..." : "Delete rule"}
413
+ </Button>
414
+ </div>
415
+ }
416
+ >
417
+ {deleteError ? (
418
+ <p className="text-sm text-ph-danger" role="alert">
419
+ {deleteError}
420
+ </p>
421
+ ) : null}
422
+ <p className="text-sm text-ph-ink">
423
+ Delete {ruleToDelete?.name}? This cannot be undone.
424
+ </p>
425
+ </Modal>
426
+
427
+ <Card
428
+ variant="outlined"
429
+ bodyClassName="p-5"
430
+ data-test={`${dataTest}-events-card`}
431
+ >
432
+ <h3 className="font-medium text-ph-ink">Recent events</h3>
433
+ <p className="mt-1 text-sm text-ph-subtle">
434
+ The latest events received from your connected apps.
435
+ </p>
436
+ {events.length === 0 ? (
437
+ <p
438
+ className="mt-4 rounded-lg border border-dashed border-ph-border px-4 py-6 text-center text-sm text-ph-subtle"
439
+ data-test={`${dataTest}-events-empty`}
440
+ >
441
+ No integration events yet.
442
+ </p>
443
+ ) : (
444
+ <ul className="mt-4 space-y-2" data-test={`${dataTest}-events-list`}>
445
+ {events.map((event) => (
446
+ <li
447
+ key={event.id}
448
+ className="flex items-center justify-between gap-3 rounded-lg border border-ph-border bg-ph-muted px-3 py-2"
449
+ >
450
+ <div className="min-w-0">
451
+ <p className="truncate text-sm text-ph-ink">
452
+ {event.provider} · {event.event_type}
453
+ </p>
454
+ <p className="mt-0.5 text-xs text-ph-subtle">
455
+ {formatDateTime(event.created_at)}
456
+ </p>
457
+ </div>
458
+ <Badge
459
+ variant={event.processed_at ? "success" : "warning"}
460
+ size="sm"
461
+ >
462
+ {event.processed_at ? "Processed" : "Pending"}
463
+ </Badge>
464
+ </li>
465
+ ))}
466
+ </ul>
467
+ )}
468
+ </Card>
469
+ </section>
470
+ );
471
+ }
@@ -1,5 +1,5 @@
1
- import { cleanup, render } from "@testing-library/react";
2
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
3
 
4
4
  import { SuiteMobileDrawer } from "./suite-mobile-drawer";
5
5
 
@@ -30,4 +30,20 @@ describe("SuiteMobileDrawer", () => {
30
30
  );
31
31
  expect(body?.previousElementSibling).toHaveClass("h-14", "shrink-0");
32
32
  });
33
+
34
+ it("uses a modal dialog that closes on Escape", () => {
35
+ const onClose = vi.fn();
36
+ render(
37
+ <SuiteMobileDrawer open onClose={onClose} showCloseButton>
38
+ <a href="/documents">Documents</a>
39
+ </SuiteMobileDrawer>,
40
+ );
41
+
42
+ expect(screen.getByRole("dialog", { name: "Navigation" })).toHaveAttribute(
43
+ "aria-modal",
44
+ "true",
45
+ );
46
+ fireEvent.keyDown(document, { key: "Escape" });
47
+ expect(onClose).toHaveBeenCalledOnce();
48
+ });
33
49
  });