@grackle-ai/web-components 0.107.2
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/.rush/temp/3ae72563f781afd72723475938136f113846603e.untar.log +10 -0
- package/.rush/temp/bc1d5bf9201ce71abeaeaddd096deb9b0805d703.untar.log +10 -0
- package/.rush/temp/operation/_phase_build/all.log +18 -0
- package/.rush/temp/operation/_phase_build/log-chunks.jsonl +18 -0
- package/.rush/temp/operation/_phase_build/state.json +3 -0
- package/.rush/temp/operation/_phase_test/all.log +121 -0
- package/.rush/temp/operation/_phase_test/log-chunks.jsonl +121 -0
- package/.rush/temp/operation/_phase_test/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +938 -0
- package/.storybook/main.ts +22 -0
- package/.storybook/preview.tsx +30 -0
- package/config/rig.json +4 -0
- package/config/rush-project.json +12 -0
- package/dist/index.css +1 -0
- package/dist/index.js +39221 -0
- package/eslint.config.cjs +5 -0
- package/package.json +83 -0
- package/rush-logs/web-components._phase_build.cache.log +4 -0
- package/rush-logs/web-components._phase_test.cache.log +4 -0
- package/src/components/chat/ChatInput.module.scss +81 -0
- package/src/components/chat/ChatInput.stories.tsx +91 -0
- package/src/components/chat/ChatInput.tsx +168 -0
- package/src/components/chat/index.ts +6 -0
- package/src/components/dag/DagView.module.scss +149 -0
- package/src/components/dag/DagView.stories.tsx +125 -0
- package/src/components/dag/DagView.tsx +109 -0
- package/src/components/dag/TaskNode.stories.tsx +133 -0
- package/src/components/dag/TaskNode.tsx +40 -0
- package/src/components/dag/useDagLayout.ts +139 -0
- package/src/components/display/Breadcrumbs.module.scss +71 -0
- package/src/components/display/Breadcrumbs.stories.tsx +80 -0
- package/src/components/display/Breadcrumbs.tsx +46 -0
- package/src/components/display/Button.module.scss +110 -0
- package/src/components/display/Button.stories.tsx +88 -0
- package/src/components/display/Button.tsx +40 -0
- package/src/components/display/ConfirmDialog.module.scss +67 -0
- package/src/components/display/ConfirmDialog.stories.tsx +81 -0
- package/src/components/display/ConfirmDialog.tsx +88 -0
- package/src/components/display/CopyButton.module.scss +41 -0
- package/src/components/display/CopyButton.stories.tsx +78 -0
- package/src/components/display/CopyButton.tsx +64 -0
- package/src/components/display/DemoBanner.module.scss +37 -0
- package/src/components/display/DemoBanner.stories.tsx +40 -0
- package/src/components/display/DemoBanner.tsx +23 -0
- package/src/components/display/EventHoverRow.module.scss +102 -0
- package/src/components/display/EventHoverRow.stories.tsx +99 -0
- package/src/components/display/EventHoverRow.tsx +154 -0
- package/src/components/display/EventRenderer.module.scss +272 -0
- package/src/components/display/EventRenderer.stories.tsx +186 -0
- package/src/components/display/EventRenderer.tsx +271 -0
- package/src/components/display/EventStream.module.scss +93 -0
- package/src/components/display/EventStream.stories.tsx +249 -0
- package/src/components/display/EventStream.tsx +369 -0
- package/src/components/display/FloatingActionBar.module.scss +107 -0
- package/src/components/display/FloatingActionBar.stories.tsx +122 -0
- package/src/components/display/FloatingActionBar.tsx +119 -0
- package/src/components/display/SessionAttemptSelector.module.scss +50 -0
- package/src/components/display/SessionAttemptSelector.stories.tsx +78 -0
- package/src/components/display/SessionAttemptSelector.tsx +49 -0
- package/src/components/display/SessionPicker.module.scss +200 -0
- package/src/components/display/SessionPicker.stories.tsx +169 -0
- package/src/components/display/SessionPicker.tsx +214 -0
- package/src/components/display/Skeleton.module.scss +58 -0
- package/src/components/display/Skeleton.stories.tsx +94 -0
- package/src/components/display/Skeleton.tsx +127 -0
- package/src/components/display/Spinner.module.scss +41 -0
- package/src/components/display/Spinner.stories.tsx +66 -0
- package/src/components/display/Spinner.tsx +32 -0
- package/src/components/display/SplashScreen.module.scss +20 -0
- package/src/components/display/SplashScreen.stories.tsx +26 -0
- package/src/components/display/SplashScreen.tsx +16 -0
- package/src/components/display/SplitButton.module.scss +166 -0
- package/src/components/display/SplitButton.stories.tsx +95 -0
- package/src/components/display/SplitButton.tsx +128 -0
- package/src/components/display/Tooltip.module.scss +84 -0
- package/src/components/display/Tooltip.stories.tsx +240 -0
- package/src/components/display/Tooltip.tsx +184 -0
- package/src/components/display/extractText.test.tsx +48 -0
- package/src/components/display/index.ts +20 -0
- package/src/components/editable/EditableCheckbox.stories.tsx +54 -0
- package/src/components/editable/EditableCheckbox.tsx +39 -0
- package/src/components/editable/EditableField.module.scss +135 -0
- package/src/components/editable/EditableSelect.tsx +164 -0
- package/src/components/editable/EditableTextArea.stories.tsx +50 -0
- package/src/components/editable/EditableTextArea.tsx +148 -0
- package/src/components/editable/EditableTextField.stories.tsx +62 -0
- package/src/components/editable/EditableTextField.tsx +153 -0
- package/src/components/editable/EnvironmentSelect.module.scss +17 -0
- package/src/components/editable/EnvironmentSelect.stories.tsx +61 -0
- package/src/components/editable/EnvironmentSelect.tsx +87 -0
- package/src/components/editable/index.ts +13 -0
- package/src/components/editable/useEditableField.test.tsx +233 -0
- package/src/components/editable/useEditableField.ts +173 -0
- package/src/components/index.ts +20 -0
- package/src/components/knowledge/KnowledgeDetailPanel.module.scss +162 -0
- package/src/components/knowledge/KnowledgeDetailPanel.stories.tsx +208 -0
- package/src/components/knowledge/KnowledgeDetailPanel.tsx +122 -0
- package/src/components/knowledge/KnowledgeGraph.module.scss +110 -0
- package/src/components/knowledge/KnowledgeGraph.stories.tsx +180 -0
- package/src/components/knowledge/KnowledgeGraph.tsx +455 -0
- package/src/components/knowledge/KnowledgeNav.module.scss +130 -0
- package/src/components/knowledge/KnowledgeNav.stories.tsx +108 -0
- package/src/components/knowledge/KnowledgeNav.tsx +138 -0
- package/src/components/knowledge/index.ts +3 -0
- package/src/components/layout/AppNav.module.scss +82 -0
- package/src/components/layout/AppNav.stories.tsx +115 -0
- package/src/components/layout/AppNav.tsx +133 -0
- package/src/components/layout/BottomStatusBar.module.scss +58 -0
- package/src/components/layout/BottomStatusBar.stories.tsx +35 -0
- package/src/components/layout/BottomStatusBar.tsx +206 -0
- package/src/components/layout/Sidebar.module.scss +60 -0
- package/src/components/layout/Sidebar.stories.tsx +46 -0
- package/src/components/layout/Sidebar.tsx +84 -0
- package/src/components/layout/StatusBar.module.scss +108 -0
- package/src/components/layout/StatusBar.stories.tsx +119 -0
- package/src/components/layout/StatusBar.tsx +70 -0
- package/src/components/layout/index.ts +9 -0
- package/src/components/lists/EnvironmentNav.module.scss +118 -0
- package/src/components/lists/EnvironmentNav.stories.tsx +121 -0
- package/src/components/lists/EnvironmentNav.tsx +133 -0
- package/src/components/lists/FindingsNav.module.scss +126 -0
- package/src/components/lists/FindingsNav.tsx +146 -0
- package/src/components/lists/TaskList.module.scss +206 -0
- package/src/components/lists/TaskList.stories.tsx +401 -0
- package/src/components/lists/TaskList.tsx +509 -0
- package/src/components/lists/index.ts +6 -0
- package/src/components/lists/listHelpers.tsx +130 -0
- package/src/components/notifications/Callout.module.scss +83 -0
- package/src/components/notifications/Callout.stories.tsx +81 -0
- package/src/components/notifications/Callout.tsx +64 -0
- package/src/components/notifications/Toast.module.scss +86 -0
- package/src/components/notifications/Toast.stories.tsx +71 -0
- package/src/components/notifications/Toast.tsx +51 -0
- package/src/components/notifications/ToastContainer.module.scss +23 -0
- package/src/components/notifications/ToastContainer.stories.tsx +66 -0
- package/src/components/notifications/ToastContainer.tsx +29 -0
- package/src/components/notifications/UpdateBanner.stories.tsx +77 -0
- package/src/components/notifications/UpdateBanner.test.tsx +64 -0
- package/src/components/notifications/UpdateBanner.tsx +44 -0
- package/src/components/notifications/index.ts +8 -0
- package/src/components/panels/AboutPanel.stories.tsx +70 -0
- package/src/components/panels/AboutPanel.tsx +66 -0
- package/src/components/panels/AppearancePanel.stories.tsx +45 -0
- package/src/components/panels/AppearancePanel.tsx +97 -0
- package/src/components/panels/CredentialProvidersPanel.stories.tsx +62 -0
- package/src/components/panels/CredentialProvidersPanel.tsx +111 -0
- package/src/components/panels/EnvironmentEditPanel.module.scss +170 -0
- package/src/components/panels/EnvironmentEditPanel.stories.tsx +206 -0
- package/src/components/panels/EnvironmentEditPanel.tsx +785 -0
- package/src/components/panels/FindingsPanel.module.scss +94 -0
- package/src/components/panels/FindingsPanel.stories.tsx +109 -0
- package/src/components/panels/FindingsPanel.tsx +76 -0
- package/src/components/panels/KeyboardShortcutsPanel.module.scss +65 -0
- package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +40 -0
- package/src/components/panels/KeyboardShortcutsPanel.tsx +104 -0
- package/src/components/panels/PluginsPanel.tsx +77 -0
- package/src/components/panels/SettingsPanel.module.scss +336 -0
- package/src/components/panels/TaskActionButtons.module.scss +22 -0
- package/src/components/panels/TaskActionButtons.stories.tsx +125 -0
- package/src/components/panels/TaskActionButtons.tsx +87 -0
- package/src/components/panels/TaskEditPanel.module.scss +202 -0
- package/src/components/panels/TaskEditPanel.stories.tsx +75 -0
- package/src/components/panels/TaskEditPanel.tsx +328 -0
- package/src/components/panels/TaskOverviewPanel.module.scss +236 -0
- package/src/components/panels/TaskOverviewPanel.stories.tsx +219 -0
- package/src/components/panels/TaskOverviewPanel.tsx +270 -0
- package/src/components/panels/TokensPanel.stories.tsx +131 -0
- package/src/components/panels/TokensPanel.tsx +143 -0
- package/src/components/panels/WorkpadPanel.module.scss +39 -0
- package/src/components/panels/WorkpadPanel.stories.tsx +56 -0
- package/src/components/panels/WorkpadPanel.tsx +63 -0
- package/src/components/panels/index.ts +13 -0
- package/src/components/personas/McpToolSelector.module.scss +109 -0
- package/src/components/personas/McpToolSelector.stories.tsx +129 -0
- package/src/components/personas/McpToolSelector.tsx +180 -0
- package/src/components/personas/PersonaManager.module.scss +233 -0
- package/src/components/personas/PersonaManager.stories.tsx +139 -0
- package/src/components/personas/PersonaManager.tsx +122 -0
- package/src/components/schedules/ScheduleManager.module.scss +98 -0
- package/src/components/schedules/ScheduleManager.stories.tsx +78 -0
- package/src/components/schedules/ScheduleManager.tsx +160 -0
- package/src/components/settings/SettingsNav.module.scss +82 -0
- package/src/components/settings/SettingsNav.stories.tsx +83 -0
- package/src/components/settings/SettingsNav.tsx +104 -0
- package/src/components/streams/StreamDetailPanel.module.scss +206 -0
- package/src/components/streams/StreamDetailPanel.stories.tsx +132 -0
- package/src/components/streams/StreamDetailPanel.tsx +119 -0
- package/src/components/streams/StreamList.module.scss +92 -0
- package/src/components/streams/StreamList.stories.tsx +99 -0
- package/src/components/streams/StreamList.tsx +114 -0
- package/src/components/streams/index.ts +10 -0
- package/src/components/tools/AgentToolCard.module.scss +118 -0
- package/src/components/tools/AgentToolCard.stories.tsx +304 -0
- package/src/components/tools/AgentToolCard.tsx +247 -0
- package/src/components/tools/FileEditCard.stories.tsx +138 -0
- package/src/components/tools/FileEditCard.tsx +160 -0
- package/src/components/tools/FileReadCard.stories.tsx +120 -0
- package/src/components/tools/FileReadCard.tsx +106 -0
- package/src/components/tools/FindingCard.stories.tsx +124 -0
- package/src/components/tools/FindingCard.tsx +178 -0
- package/src/components/tools/GenericToolCard.stories.tsx +80 -0
- package/src/components/tools/GenericToolCard.tsx +111 -0
- package/src/components/tools/IpcCard.stories.tsx +129 -0
- package/src/components/tools/IpcCard.tsx +178 -0
- package/src/components/tools/KnowledgeCard.stories.tsx +112 -0
- package/src/components/tools/KnowledgeCard.tsx +165 -0
- package/src/components/tools/MetadataCard.stories.tsx +32 -0
- package/src/components/tools/MetadataCard.tsx +39 -0
- package/src/components/tools/SearchCard.stories.tsx +74 -0
- package/src/components/tools/SearchCard.tsx +86 -0
- package/src/components/tools/ShellCard.stories.tsx +112 -0
- package/src/components/tools/ShellCard.tsx +106 -0
- package/src/components/tools/TaskCard.stories.tsx +123 -0
- package/src/components/tools/TaskCard.tsx +203 -0
- package/src/components/tools/TodoCard.module.scss +131 -0
- package/src/components/tools/TodoCard.stories.tsx +202 -0
- package/src/components/tools/TodoCard.tsx +200 -0
- package/src/components/tools/ToolCard.stories.tsx +177 -0
- package/src/components/tools/ToolCard.tsx +60 -0
- package/src/components/tools/ToolCardProps.ts +20 -0
- package/src/components/tools/ToolSearchCard.stories.tsx +81 -0
- package/src/components/tools/ToolSearchCard.tsx +86 -0
- package/src/components/tools/WorkpadCard.stories.tsx +106 -0
- package/src/components/tools/WorkpadCard.tsx +125 -0
- package/src/components/tools/classifyTool.test.ts +44 -0
- package/src/components/tools/classifyTool.ts +134 -0
- package/src/components/tools/parseDiff.ts +95 -0
- package/src/components/tools/parseShellOutput.ts +28 -0
- package/src/components/tools/toolCardHelpers.test.ts +53 -0
- package/src/components/tools/toolCards.module.scss +234 -0
- package/src/components/workspace/WorkspaceBoard.module.scss +238 -0
- package/src/components/workspace/WorkspaceBoard.stories.tsx +240 -0
- package/src/components/workspace/WorkspaceBoard.tsx +232 -0
- package/src/components/workspace/WorkspaceFormFields.module.scss +79 -0
- package/src/components/workspace/WorkspaceFormFields.stories.tsx +133 -0
- package/src/components/workspace/WorkspaceFormFields.tsx +185 -0
- package/src/context/GrackleContext.ts +28 -0
- package/src/context/GrackleContextTypes.ts +64 -0
- package/src/context/SidebarContext.tsx +53 -0
- package/src/context/ThemeContext.tsx +21 -0
- package/src/context/ToastContext.tsx +56 -0
- package/src/hooks/types.ts +864 -0
- package/src/hooks/useEventSelection.test.ts +204 -0
- package/src/hooks/useEventSelection.ts +158 -0
- package/src/hooks/useSmartScroll.ts +151 -0
- package/src/hooks/useTheme.ts +228 -0
- package/src/index.ts +210 -0
- package/src/mocks/MockGrackleProvider.tsx +1397 -0
- package/src/mocks/mockData.ts +1966 -0
- package/src/mocks/mockKnowledgeData.ts +294 -0
- package/src/scss.d.ts +12 -0
- package/src/styles/global.scss +244 -0
- package/src/styles/mixins.scss +278 -0
- package/src/styles/prism-theme.scss +148 -0
- package/src/styles/theme.scss +1102 -0
- package/src/test-utils/storybook-decorators.tsx +50 -0
- package/src/test-utils/storybook-helpers.ts +262 -0
- package/src/themes.ts +142 -0
- package/src/utils/boardColumns.ts +141 -0
- package/src/utils/breadcrumbs.test.ts +285 -0
- package/src/utils/breadcrumbs.ts +222 -0
- package/src/utils/dashboard.test.ts +156 -0
- package/src/utils/dashboard.ts +195 -0
- package/src/utils/eventContent.test.ts +353 -0
- package/src/utils/eventContent.ts +209 -0
- package/src/utils/findingCategory.ts +33 -0
- package/src/utils/format.ts +27 -0
- package/src/utils/iconSize.ts +18 -0
- package/src/utils/navigation.ts +205 -0
- package/src/utils/route-config.test.ts +128 -0
- package/src/utils/scrollUtils.test.ts +65 -0
- package/src/utils/scrollUtils.ts +49 -0
- package/src/utils/sessionEvents.test.ts +302 -0
- package/src/utils/sessionEvents.ts +233 -0
- package/src/utils/taskStatus.tsx +137 -0
- package/src/utils/time.ts +92 -0
- package/tsconfig.json +8 -0
- package/vite.config.ts +20 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useEffect, useRef, type JSX, type ReactNode } from "react";
|
|
2
|
+
import { useEditableField } from "./useEditableField.js";
|
|
3
|
+
import styles from "./EditableField.module.scss";
|
|
4
|
+
|
|
5
|
+
/** Props for EditableTextField. */
|
|
6
|
+
export interface EditableTextFieldProps {
|
|
7
|
+
/** Current persisted value. */
|
|
8
|
+
value: string;
|
|
9
|
+
/** Called when the user saves. Required in edit mode. */
|
|
10
|
+
onSave: (value: string) => void;
|
|
11
|
+
/** Optional validation — return an error string, or undefined if valid. */
|
|
12
|
+
validate?: (value: string) => string | undefined;
|
|
13
|
+
/** "edit" (default) for click-to-edit, "create" for always-editable. */
|
|
14
|
+
mode?: "edit" | "create";
|
|
15
|
+
/** Unique field identifier for coordination. */
|
|
16
|
+
fieldId?: string;
|
|
17
|
+
/** Which field is currently being edited (parent coordination). */
|
|
18
|
+
activeFieldId?: string | null; // eslint-disable-line @rushstack/no-new-null
|
|
19
|
+
/** Callback to tell the parent which field is active. */
|
|
20
|
+
onActivate?: (fieldId: string | null) => void; // eslint-disable-line @rushstack/no-new-null
|
|
21
|
+
/** Called on every keystroke in create mode. */
|
|
22
|
+
onChange?: (value: string) => void;
|
|
23
|
+
/** Custom display renderer (e.g., link for repoUrl). */
|
|
24
|
+
renderDisplay?: (value: string) => ReactNode | undefined;
|
|
25
|
+
/** Placeholder text shown when empty. */
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
/** Max character length for the input. */
|
|
28
|
+
maxLength?: number;
|
|
29
|
+
/** Accessible label for the input. */
|
|
30
|
+
ariaLabel?: string;
|
|
31
|
+
/** Base test ID — gets `-input` / `-button` suffixes appended. */
|
|
32
|
+
"data-testid"?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Reusable click-to-edit text input field. */
|
|
36
|
+
export function EditableTextField(props: EditableTextFieldProps): JSX.Element {
|
|
37
|
+
const {
|
|
38
|
+
value,
|
|
39
|
+
onSave,
|
|
40
|
+
validate,
|
|
41
|
+
mode = "edit",
|
|
42
|
+
fieldId = "text",
|
|
43
|
+
activeFieldId,
|
|
44
|
+
onActivate,
|
|
45
|
+
onChange,
|
|
46
|
+
renderDisplay,
|
|
47
|
+
placeholder,
|
|
48
|
+
maxLength,
|
|
49
|
+
ariaLabel,
|
|
50
|
+
"data-testid": testId,
|
|
51
|
+
} = props;
|
|
52
|
+
|
|
53
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
54
|
+
|
|
55
|
+
const field = useEditableField({
|
|
56
|
+
value,
|
|
57
|
+
onSave,
|
|
58
|
+
validate,
|
|
59
|
+
fieldId,
|
|
60
|
+
activeFieldId,
|
|
61
|
+
onActivate,
|
|
62
|
+
enterToSave: true,
|
|
63
|
+
trimOnSave: true,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Auto-focus when entering edit mode
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (field.isEditing) {
|
|
69
|
+
const timer = window.setTimeout(() => {
|
|
70
|
+
inputRef.current?.focus();
|
|
71
|
+
}, 0);
|
|
72
|
+
return () => window.clearTimeout(timer);
|
|
73
|
+
}
|
|
74
|
+
}, [field.isEditing]);
|
|
75
|
+
|
|
76
|
+
// Create mode: always show input, no blur-to-save
|
|
77
|
+
if (mode === "create") {
|
|
78
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
|
79
|
+
onChange?.(e.target.value);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const validationError = validate?.(value);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className={styles.editFieldWrapper}>
|
|
86
|
+
<input
|
|
87
|
+
className={`${styles.editInput} ${validationError ? styles.editInputInvalid : ""}`}
|
|
88
|
+
value={value}
|
|
89
|
+
onChange={handleChange}
|
|
90
|
+
maxLength={maxLength}
|
|
91
|
+
placeholder={placeholder}
|
|
92
|
+
aria-label={ariaLabel}
|
|
93
|
+
data-testid={testId ? `${testId}-input` : undefined}
|
|
94
|
+
/>
|
|
95
|
+
{validationError && (
|
|
96
|
+
<span className={styles.editError} data-testid="edit-error">{validationError}</span>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Edit mode: toggle between display and input
|
|
103
|
+
if (field.isEditing) {
|
|
104
|
+
return (
|
|
105
|
+
<div className={styles.editFieldWrapper}>
|
|
106
|
+
<input
|
|
107
|
+
ref={inputRef}
|
|
108
|
+
className={`${styles.editInput} ${field.error ? styles.editInputInvalid : ""}`}
|
|
109
|
+
value={field.draft}
|
|
110
|
+
onChange={(e) => field.setDraft(e.target.value)}
|
|
111
|
+
onBlur={field.handleBlur}
|
|
112
|
+
onKeyDown={field.handleKeyDown}
|
|
113
|
+
maxLength={maxLength}
|
|
114
|
+
placeholder={placeholder}
|
|
115
|
+
aria-label={ariaLabel}
|
|
116
|
+
data-testid={testId ? `${testId}-input` : undefined}
|
|
117
|
+
/>
|
|
118
|
+
{field.isDirty && <span className={styles.unsavedDot} title="Unsaved changes" />}
|
|
119
|
+
{field.error && (
|
|
120
|
+
<span className={styles.editError} data-testid="edit-error">{field.error}</span>
|
|
121
|
+
)}
|
|
122
|
+
<span className={styles.editHint}>Enter to save · Esc to cancel</span>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Display mode — uses <span role="button"> to avoid nested interactive elements
|
|
128
|
+
// when renderDisplay returns links or other interactive content
|
|
129
|
+
const displayContent = renderDisplay?.(value);
|
|
130
|
+
return (
|
|
131
|
+
<span
|
|
132
|
+
role="button"
|
|
133
|
+
tabIndex={0}
|
|
134
|
+
className={styles.metaValueClickable}
|
|
135
|
+
onClick={() => field.startEdit()}
|
|
136
|
+
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); field.startEdit(); } }}
|
|
137
|
+
title="Click to edit"
|
|
138
|
+
aria-label={ariaLabel}
|
|
139
|
+
data-testid={testId ? `${testId}-button` : undefined}
|
|
140
|
+
>
|
|
141
|
+
{displayContent !== undefined ? displayContent : (
|
|
142
|
+
value ? (
|
|
143
|
+
<span>{value}</span>
|
|
144
|
+
) : (
|
|
145
|
+
<span className={styles.metaPlaceholder}>{placeholder || "None"}</span>
|
|
146
|
+
)
|
|
147
|
+
)}
|
|
148
|
+
<span className={styles.editButton} aria-hidden="true">
|
|
149
|
+
✏️
|
|
150
|
+
</span>
|
|
151
|
+
</span>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.envRow {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: var(--space-sm);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.envDot {
|
|
8
|
+
width: 8px;
|
|
9
|
+
height: 8px;
|
|
10
|
+
border-radius: 50%;
|
|
11
|
+
flex-shrink: 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.envDotGreen { background: var(--accent-green); }
|
|
15
|
+
.envDotYellow { background: var(--accent-yellow); }
|
|
16
|
+
.envDotRed { background: var(--accent-red); }
|
|
17
|
+
.envDotGray { background: var(--text-disabled); }
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
+
import { expect, fn } from "@storybook/test";
|
|
3
|
+
import { EnvironmentSelect } from "./EnvironmentSelect.js";
|
|
4
|
+
import type { Environment } from "../../hooks/types.js";
|
|
5
|
+
import { makeEnvironment } from "../../test-utils/storybook-helpers.js";
|
|
6
|
+
|
|
7
|
+
const localEnv: Environment = makeEnvironment({
|
|
8
|
+
id: "env-local",
|
|
9
|
+
displayName: "Local Machine",
|
|
10
|
+
adapterType: "local",
|
|
11
|
+
status: "connected",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const sshEnv: Environment = makeEnvironment({
|
|
15
|
+
id: "env-ssh",
|
|
16
|
+
displayName: "Dev Server (SSH)",
|
|
17
|
+
adapterType: "ssh",
|
|
18
|
+
status: "ready",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const failedEnv: Environment = makeEnvironment({
|
|
22
|
+
id: "env-fail",
|
|
23
|
+
displayName: "Broken Host",
|
|
24
|
+
adapterType: "ssh",
|
|
25
|
+
status: "error",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const meta: Meta<typeof EnvironmentSelect> = {
|
|
29
|
+
component: EnvironmentSelect,
|
|
30
|
+
title: "App/Editable/EnvironmentSelect",
|
|
31
|
+
args: {
|
|
32
|
+
onSave: fn(),
|
|
33
|
+
environments: [localEnv, sshEnv],
|
|
34
|
+
value: localEnv.id,
|
|
35
|
+
"data-testid": "env-select",
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
export default meta;
|
|
39
|
+
type Story = StoryObj<typeof meta>;
|
|
40
|
+
|
|
41
|
+
/** Default state showing the selected environment with status dot. */
|
|
42
|
+
export const Default: Story = {
|
|
43
|
+
play: async ({ canvas }) => {
|
|
44
|
+
const button = canvas.getByTestId("env-select-button");
|
|
45
|
+
await expect(button).toBeInTheDocument();
|
|
46
|
+
await expect(button).toHaveTextContent("Local Machine");
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Multiple environments including various statuses. */
|
|
51
|
+
export const WithMultipleEnvironments: Story = {
|
|
52
|
+
args: {
|
|
53
|
+
environments: [localEnv, sshEnv, failedEnv],
|
|
54
|
+
value: sshEnv.id,
|
|
55
|
+
},
|
|
56
|
+
play: async ({ canvas }) => {
|
|
57
|
+
const button = canvas.getByTestId("env-select-button");
|
|
58
|
+
await expect(button).toBeInTheDocument();
|
|
59
|
+
await expect(button).toHaveTextContent("Dev Server (SSH)");
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type JSX, type ReactNode } from "react";
|
|
2
|
+
import { EditableSelect } from "./EditableSelect.js";
|
|
3
|
+
import type { Environment } from "../../hooks/types.js";
|
|
4
|
+
import styles from "./EnvironmentSelect.module.scss";
|
|
5
|
+
|
|
6
|
+
/** Props for EnvironmentSelect. */
|
|
7
|
+
export interface EnvironmentSelectProps {
|
|
8
|
+
/** Currently selected environment ID. */
|
|
9
|
+
value: string;
|
|
10
|
+
/** Called when the user selects a new environment. */
|
|
11
|
+
onSave: (envId: string) => void;
|
|
12
|
+
/** Available environments. */
|
|
13
|
+
environments: Environment[];
|
|
14
|
+
/** Whether to include a "None" option. */
|
|
15
|
+
allowNone?: boolean;
|
|
16
|
+
/** Unique field identifier for coordination with other editable fields. */
|
|
17
|
+
fieldId?: string;
|
|
18
|
+
/** Which field is currently being edited (parent coordination). */
|
|
19
|
+
activeFieldId?: string | null; // eslint-disable-line @rushstack/no-new-null
|
|
20
|
+
/** Callback to tell the parent which field is active. */
|
|
21
|
+
onActivate?: (fieldId: string | null) => void; // eslint-disable-line @rushstack/no-new-null
|
|
22
|
+
/** Placeholder text when no value is selected. */
|
|
23
|
+
placeholder?: string;
|
|
24
|
+
/** Accessible label. */
|
|
25
|
+
ariaLabel?: string;
|
|
26
|
+
/** Base test ID. */
|
|
27
|
+
"data-testid"?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Map environment status to a CSS class for the status dot. */
|
|
31
|
+
function envStatusClass(status: string): string {
|
|
32
|
+
const s = status.toLowerCase();
|
|
33
|
+
if (s === "ready" || s === "running" || s === "available" || s === "connected") return styles.envDotGreen;
|
|
34
|
+
if (s === "provisioning" || s === "starting" || s === "pending" || s === "connecting") return styles.envDotYellow;
|
|
35
|
+
if (s === "error" || s === "failed" || s === "disconnected") return styles.envDotRed;
|
|
36
|
+
return styles.envDotGray;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Reusable environment selector with status dot display. Click-to-edit EditableSelect. */
|
|
40
|
+
export function EnvironmentSelect(props: EnvironmentSelectProps): JSX.Element {
|
|
41
|
+
const {
|
|
42
|
+
value,
|
|
43
|
+
onSave,
|
|
44
|
+
environments,
|
|
45
|
+
allowNone = false,
|
|
46
|
+
fieldId = "environment",
|
|
47
|
+
activeFieldId,
|
|
48
|
+
onActivate,
|
|
49
|
+
placeholder = "No environment",
|
|
50
|
+
ariaLabel = "Environment",
|
|
51
|
+
"data-testid": testId,
|
|
52
|
+
} = props;
|
|
53
|
+
|
|
54
|
+
const selectedEnv = environments.find((e) => e.id === value);
|
|
55
|
+
|
|
56
|
+
const options = [
|
|
57
|
+
...(allowNone ? [{ value: "", label: "None" }] : []),
|
|
58
|
+
...environments.map((env) => ({ value: env.id, label: env.displayName })),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const renderDisplay = (): ReactNode | undefined => {
|
|
62
|
+
if (selectedEnv) {
|
|
63
|
+
return (
|
|
64
|
+
<span className={styles.envRow}>
|
|
65
|
+
<span className={`${styles.envDot} ${envStatusClass(selectedEnv.status)}`} />
|
|
66
|
+
{selectedEnv.displayName}
|
|
67
|
+
</span>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<EditableSelect
|
|
75
|
+
value={value}
|
|
76
|
+
onSave={onSave}
|
|
77
|
+
options={options}
|
|
78
|
+
fieldId={fieldId}
|
|
79
|
+
activeFieldId={activeFieldId}
|
|
80
|
+
onActivate={onActivate}
|
|
81
|
+
renderDisplay={renderDisplay}
|
|
82
|
+
placeholder={placeholder}
|
|
83
|
+
ariaLabel={ariaLabel}
|
|
84
|
+
data-testid={testId}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { EditableTextField } from "./EditableTextField.js";
|
|
2
|
+
export { EditableTextArea } from "./EditableTextArea.js";
|
|
3
|
+
export { EditableSelect } from "./EditableSelect.js";
|
|
4
|
+
export { EditableCheckbox } from "./EditableCheckbox.js";
|
|
5
|
+
export { EnvironmentSelect } from "./EnvironmentSelect.js";
|
|
6
|
+
export { useEditableField } from "./useEditableField.js";
|
|
7
|
+
|
|
8
|
+
export type { EditableTextFieldProps } from "./EditableTextField.js";
|
|
9
|
+
export type { EditableTextAreaProps } from "./EditableTextArea.js";
|
|
10
|
+
export type { EditableSelectProps, SelectOption } from "./EditableSelect.js";
|
|
11
|
+
export type { EditableCheckboxProps } from "./EditableCheckbox.js";
|
|
12
|
+
export type { EnvironmentSelectProps } from "./EnvironmentSelect.js";
|
|
13
|
+
export type { UseEditableFieldOptions, UseEditableFieldReturn } from "./useEditableField.js";
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi } from "vitest";
|
|
3
|
+
import { renderHook, act } from "@testing-library/react";
|
|
4
|
+
import { useEditableField } from "./useEditableField.js";
|
|
5
|
+
|
|
6
|
+
function makeOptions(overrides: Partial<Parameters<typeof useEditableField>[0]> = {}): Parameters<typeof useEditableField>[0] {
|
|
7
|
+
return {
|
|
8
|
+
value: "hello",
|
|
9
|
+
onSave: vi.fn(),
|
|
10
|
+
fieldId: "name",
|
|
11
|
+
activeFieldId: null as string | null,
|
|
12
|
+
onActivate: vi.fn(),
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("useEditableField", () => {
|
|
18
|
+
// ── Lifecycle: start / cancel / save ──────────────────────────
|
|
19
|
+
it("starts in non-editing state", () => {
|
|
20
|
+
const opts = makeOptions();
|
|
21
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
22
|
+
expect(result.current.isEditing).toBe(false);
|
|
23
|
+
expect(result.current.draft).toBe("");
|
|
24
|
+
expect(result.current.error).toBe("");
|
|
25
|
+
expect(result.current.isDirty).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("startEdit activates the field and seeds the draft", () => {
|
|
29
|
+
const opts = makeOptions();
|
|
30
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
31
|
+
|
|
32
|
+
act(() => result.current.startEdit());
|
|
33
|
+
expect(opts.onActivate).toHaveBeenCalledWith("name");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("cancelEdit deactivates and clears state", () => {
|
|
37
|
+
const opts = makeOptions({ activeFieldId: "name" });
|
|
38
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
39
|
+
|
|
40
|
+
// Seed draft
|
|
41
|
+
act(() => result.current.startEdit());
|
|
42
|
+
act(() => result.current.cancelEdit());
|
|
43
|
+
|
|
44
|
+
expect(opts.onActivate).toHaveBeenLastCalledWith(null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("save calls onSave with trimmed value and exits", () => {
|
|
48
|
+
const onSave = vi.fn();
|
|
49
|
+
const opts = makeOptions({ value: "old", activeFieldId: "name", onSave });
|
|
50
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
51
|
+
|
|
52
|
+
act(() => result.current.startEdit());
|
|
53
|
+
act(() => result.current.setDraft(" new "));
|
|
54
|
+
act(() => result.current.save());
|
|
55
|
+
|
|
56
|
+
expect(onSave).toHaveBeenCalledWith("new");
|
|
57
|
+
expect(opts.onActivate).toHaveBeenLastCalledWith(null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("save without trimOnSave preserves whitespace", () => {
|
|
61
|
+
const onSave = vi.fn();
|
|
62
|
+
const opts = makeOptions({ value: "old", activeFieldId: "name", onSave, trimOnSave: false });
|
|
63
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
64
|
+
|
|
65
|
+
act(() => result.current.startEdit());
|
|
66
|
+
act(() => result.current.setDraft(" new "));
|
|
67
|
+
act(() => result.current.save());
|
|
68
|
+
|
|
69
|
+
expect(onSave).toHaveBeenCalledWith(" new ");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("no-op save when value is unchanged", () => {
|
|
73
|
+
const onSave = vi.fn();
|
|
74
|
+
const opts = makeOptions({ value: "hello", activeFieldId: "name", onSave });
|
|
75
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
76
|
+
|
|
77
|
+
act(() => result.current.startEdit());
|
|
78
|
+
// Draft is seeded with "hello" by startEdit, don't change it
|
|
79
|
+
act(() => result.current.save());
|
|
80
|
+
|
|
81
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
82
|
+
expect(opts.onActivate).toHaveBeenLastCalledWith(null); // Still exits edit mode
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── Validation ────────────────────────────────────────────────
|
|
86
|
+
it("save with validation error shows error and does not call onSave", () => {
|
|
87
|
+
const onSave = vi.fn();
|
|
88
|
+
const validate = vi.fn().mockReturnValue("Required");
|
|
89
|
+
const opts = makeOptions({ value: "old", activeFieldId: "name", onSave, validate });
|
|
90
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
91
|
+
|
|
92
|
+
act(() => result.current.startEdit());
|
|
93
|
+
act(() => result.current.setDraft(""));
|
|
94
|
+
act(() => result.current.save());
|
|
95
|
+
|
|
96
|
+
expect(result.current.error).toBe("Required");
|
|
97
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("setDraft clears the error", () => {
|
|
101
|
+
const validate = vi.fn().mockReturnValue("Required");
|
|
102
|
+
const opts = makeOptions({ value: "old", activeFieldId: "name", validate });
|
|
103
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
104
|
+
|
|
105
|
+
act(() => result.current.startEdit());
|
|
106
|
+
act(() => result.current.setDraft(""));
|
|
107
|
+
act(() => result.current.save());
|
|
108
|
+
expect(result.current.error).toBe("Required");
|
|
109
|
+
|
|
110
|
+
act(() => result.current.setDraft("fixed"));
|
|
111
|
+
expect(result.current.error).toBe("");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── isDirty ───────────────────────────────────────────────────
|
|
115
|
+
it("isDirty is true when draft differs from value", () => {
|
|
116
|
+
const opts = makeOptions({ value: "hello", activeFieldId: "name" });
|
|
117
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
118
|
+
|
|
119
|
+
act(() => result.current.startEdit());
|
|
120
|
+
act(() => result.current.setDraft("changed"));
|
|
121
|
+
expect(result.current.isDirty).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("isDirty is false when draft matches value (after trim)", () => {
|
|
125
|
+
const opts = makeOptions({ value: "hello", activeFieldId: "name" });
|
|
126
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
127
|
+
|
|
128
|
+
act(() => result.current.startEdit());
|
|
129
|
+
act(() => result.current.setDraft(" hello "));
|
|
130
|
+
expect(result.current.isDirty).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("isDirty is false when not editing", () => {
|
|
134
|
+
const opts = makeOptions({ value: "hello", activeFieldId: null });
|
|
135
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
136
|
+
expect(result.current.isDirty).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ── Keyboard handling ─────────────────────────────────────────
|
|
140
|
+
it("Escape cancels edit", () => {
|
|
141
|
+
const opts = makeOptions({ activeFieldId: "name" });
|
|
142
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
143
|
+
|
|
144
|
+
act(() => result.current.startEdit());
|
|
145
|
+
act(() => {
|
|
146
|
+
result.current.handleKeyDown({ key: "Escape" } as React.KeyboardEvent);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(opts.onActivate).toHaveBeenLastCalledWith(null);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("Enter saves when enterToSave is true", () => {
|
|
153
|
+
const onSave = vi.fn();
|
|
154
|
+
const opts = makeOptions({ value: "old", activeFieldId: "name", onSave, enterToSave: true });
|
|
155
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
156
|
+
|
|
157
|
+
act(() => result.current.startEdit());
|
|
158
|
+
act(() => result.current.setDraft("new"));
|
|
159
|
+
act(() => {
|
|
160
|
+
result.current.handleKeyDown({ key: "Enter" } as React.KeyboardEvent);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(onSave).toHaveBeenCalledWith("new");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("Enter does NOT save when enterToSave is false", () => {
|
|
167
|
+
const onSave = vi.fn();
|
|
168
|
+
const opts = makeOptions({ value: "old", activeFieldId: "name", onSave, enterToSave: false });
|
|
169
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
170
|
+
|
|
171
|
+
act(() => result.current.startEdit());
|
|
172
|
+
act(() => result.current.setDraft("new"));
|
|
173
|
+
act(() => {
|
|
174
|
+
result.current.handleKeyDown({ key: "Enter" } as React.KeyboardEvent);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── Blur guard ────────────────────────────────────────────────
|
|
181
|
+
it("ignoreInitialBlurRef prevents first blur from saving", () => {
|
|
182
|
+
const onSave = vi.fn();
|
|
183
|
+
const opts = makeOptions({ value: "old", activeFieldId: "name", onSave });
|
|
184
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
185
|
+
|
|
186
|
+
act(() => result.current.startEdit());
|
|
187
|
+
expect(result.current.ignoreInitialBlurRef.current).toBe(true);
|
|
188
|
+
|
|
189
|
+
// Simulate first blur — should be ignored
|
|
190
|
+
act(() => {
|
|
191
|
+
result.current.handleBlur({ relatedTarget: null } as unknown as React.FocusEvent);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
195
|
+
expect(result.current.ignoreInitialBlurRef.current).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("blur with data-edit-action matching fieldId is ignored", () => {
|
|
199
|
+
const onSave = vi.fn();
|
|
200
|
+
const opts = makeOptions({ value: "old", activeFieldId: "name", onSave });
|
|
201
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
202
|
+
|
|
203
|
+
act(() => result.current.startEdit());
|
|
204
|
+
// Clear the initial blur guard
|
|
205
|
+
result.current.ignoreInitialBlurRef.current = false;
|
|
206
|
+
|
|
207
|
+
// Simulate blur to a related element with matching data-edit-action
|
|
208
|
+
const relatedTarget = document.createElement("button");
|
|
209
|
+
relatedTarget.dataset.editAction = "name";
|
|
210
|
+
|
|
211
|
+
act(() => {
|
|
212
|
+
result.current.handleBlur({ relatedTarget } as unknown as React.FocusEvent);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(onSave).not.toHaveBeenCalled();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("blur to unrelated element triggers save", () => {
|
|
219
|
+
const onSave = vi.fn();
|
|
220
|
+
const opts = makeOptions({ value: "old", activeFieldId: "name", onSave });
|
|
221
|
+
const { result } = renderHook(() => useEditableField(opts));
|
|
222
|
+
|
|
223
|
+
act(() => result.current.startEdit());
|
|
224
|
+
result.current.ignoreInitialBlurRef.current = false;
|
|
225
|
+
act(() => result.current.setDraft("new"));
|
|
226
|
+
|
|
227
|
+
act(() => {
|
|
228
|
+
result.current.handleBlur({ relatedTarget: null } as unknown as React.FocusEvent);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(onSave).toHaveBeenCalledWith("new");
|
|
232
|
+
});
|
|
233
|
+
});
|