diff --git a/packages/app/src/components/add-host-modal.tsx b/packages/app/src/components/add-host-modal.tsx index 9ad4af730..6bd72e2ad 100644 --- a/packages/app/src/components/add-host-modal.tsx +++ b/packages/app/src/components/add-host-modal.tsx @@ -319,8 +319,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod }, []); const connectIcon = useMemo( - () => , - [theme.colors.palette.white], + () => , + [theme.colors.accentForeground], ); const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []); const portFieldStyle = useMemo(() => [styles.field, styles.portField], []); diff --git a/packages/app/src/components/pair-link-modal.tsx b/packages/app/src/components/pair-link-modal.tsx index ff7ecbf46..57169ee09 100644 --- a/packages/app/src/components/pair-link-modal.tsx +++ b/packages/app/src/components/pair-link-modal.tsx @@ -77,8 +77,8 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM }, []); const pairIcon = useMemo( - () => , - [theme.colors.palette.white], + () => , + [theme.colors.accentForeground], ); const handleClose = useCallback(() => { diff --git a/packages/app/src/components/ui/button.tsx b/packages/app/src/components/ui/button.tsx index b705ffbce..e6bb7b349 100644 --- a/packages/app/src/components/ui/button.tsx +++ b/packages/app/src/components/ui/button.tsx @@ -94,10 +94,10 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, }, textDefault: { - color: theme.colors.palette.white, + color: theme.colors.accentForeground, }, textDestructive: { - color: theme.colors.palette.white, + color: theme.colors.destructiveForeground, }, textGhost: { color: theme.colors.foregroundMuted, diff --git a/packages/app/src/components/ui/form-field.tsx b/packages/app/src/components/ui/form-field.tsx new file mode 100644 index 000000000..a353807ea --- /dev/null +++ b/packages/app/src/components/ui/form-field.tsx @@ -0,0 +1,82 @@ +import { forwardRef, useMemo, type ReactNode } from "react"; +import { Text, View, type TextInput } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet"; + +interface FormFieldProps { + label: string; + children: ReactNode; + hint?: string; + error?: string | null; + testID?: string; +} + +export function FormField({ label, children, hint, error, testID }: FormFieldProps) { + const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]); + const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]); + const hintOrError = useMemo(() => { + if (error) { + return ( + + {error} + + ); + } + if (hint) { + return ( + + {hint} + + ); + } + return null; + }, [error, hint, errorTestID, hintTestID]); + + return ( + + {label} + {children} + {hintOrError} + + ); +} + +export const FormTextInput = forwardRef(function FormTextInput( + { style, ...props }, + ref, +) { + const inputStyle = useMemo(() => [formInputStyles.input, style], [style]); + return ; +}); + +const styles = StyleSheet.create((theme) => ({ + container: { + gap: theme.spacing[2], + }, + label: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + }, + hintText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, + errorText: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.xs, + }, +})); + +const formInputStyles = StyleSheet.create((theme) => ({ + input: { + backgroundColor: theme.colors.surface2, + borderRadius: theme.borderRadius.lg, + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + color: theme.colors.foreground, + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.base, + }, +})); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b05a89fbf..d9eeea9fd 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1642,7 +1642,10 @@ export const ar: TranslationResources = { nameLabel: "Name", commandLabel: "Command", argsLabel: "Arguments", + nameRequired: "Name is required", + commandRequired: "Command is required", argsHint: "Space-separated arguments passed to the command", + saving: "Saving...", remove: "Remove", removeConfirmTitle: "Remove profile?", removeConfirmMessage: 'Remove "{{name}}"?', diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index a0cb235c6..5725fa6ff 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1648,7 +1648,10 @@ export const en = { nameLabel: "Name", commandLabel: "Command", argsLabel: "Arguments", + nameRequired: "Name is required", + commandRequired: "Command is required", argsHint: "Space-separated arguments passed to the command", + saving: "Saving...", remove: "Remove", removeConfirmTitle: "Remove profile?", removeConfirmMessage: 'Remove "{{name}}"?', diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index d1ba3d9df..ceb2a6ec9 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1680,7 +1680,10 @@ export const es: TranslationResources = { nameLabel: "Name", commandLabel: "Command", argsLabel: "Arguments", + nameRequired: "Name is required", + commandRequired: "Command is required", argsHint: "Space-separated arguments passed to the command", + saving: "Saving...", remove: "Remove", removeConfirmTitle: "Remove profile?", removeConfirmMessage: 'Remove "{{name}}"?', diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index b7c013bcb..30d784e57 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1685,7 +1685,10 @@ export const fr: TranslationResources = { nameLabel: "Name", commandLabel: "Command", argsLabel: "Arguments", + nameRequired: "Name is required", + commandRequired: "Command is required", argsHint: "Space-separated arguments passed to the command", + saving: "Saving...", remove: "Remove", removeConfirmTitle: "Remove profile?", removeConfirmMessage: 'Remove "{{name}}"?', diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 693d36995..3e19f1532 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1672,7 +1672,10 @@ export const ru: TranslationResources = { nameLabel: "Name", commandLabel: "Command", argsLabel: "Arguments", + nameRequired: "Name is required", + commandRequired: "Command is required", argsHint: "Space-separated arguments passed to the command", + saving: "Saving...", remove: "Remove", removeConfirmTitle: "Remove profile?", removeConfirmMessage: 'Remove "{{name}}"?', diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index db02a1e92..c36537f2c 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1623,7 +1623,10 @@ export const zhCN: TranslationResources = { nameLabel: "Name", commandLabel: "Command", argsLabel: "Arguments", + nameRequired: "Name is required", + commandRequired: "Command is required", argsHint: "Space-separated arguments passed to the command", + saving: "Saving...", remove: "Remove", removeConfirmTitle: "Remove profile?", removeConfirmMessage: 'Remove "{{name}}"?', diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 2ac6d6a36..919eb41da 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -20,12 +20,15 @@ import { getTerminalProfileIcon, resolveTerminalProfiles, } from "@getpaseo/protocol/terminal-profiles"; -import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; +import { + ProfileDraft, + TerminalProfileEditModal, +} from "@/screens/settings/terminal-profile-edit-modal"; import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status"; @@ -1109,127 +1112,8 @@ function parseArgsString(raw: string): string[] | undefined { .filter(Boolean); } -interface ProfileDraft { - name: string; - command: string; - args: string; -} - const EMPTY_PROFILE_DRAFT: ProfileDraft = { name: "", command: "", args: "" }; -interface TerminalProfileEditModalProps { - visible: boolean; - title: string; - initialDraft: ProfileDraft; - onClose: () => void; - onSave: (draft: ProfileDraft) => void; -} - -function TerminalProfileEditModal({ - visible, - title, - initialDraft, - onClose, - onSave, -}: TerminalProfileEditModalProps) { - const { t } = useTranslation(); - const [name, setName] = useState(initialDraft.name); - const [command, setCommand] = useState(initialDraft.command); - const [args, setArgs] = useState(initialDraft.args); - const sheetHeader = useMemo(() => ({ title }), [title]); - - useEffect(() => { - if (!visible) return; - setName(initialDraft.name); - setCommand(initialDraft.command); - setArgs(initialDraft.args); - }, [visible, initialDraft.name, initialDraft.command, initialDraft.args]); - - const handleSave = useCallback(() => { - onSave({ name, command, args }); - }, [onSave, name, command, args]); - - const canSave = name.trim().length > 0 && command.trim().length > 0; - - return ( - - - - - {t("settings.host.terminalProfiles.nameLabel")} - - - - - - {t("settings.host.terminalProfiles.commandLabel")} - - - - - - {t("settings.host.terminalProfiles.argsLabel")} - - - - {t("settings.host.terminalProfiles.argsHint")} - - - - - - - - - ); -} - interface TerminalProfileRowProps { profile: TerminalProfile; isFirst: boolean; @@ -1338,7 +1222,6 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) { draft: ProfileDraft; } | null>(null); const [isAdding, setIsAdding] = useState(false); - const [isSaving, setIsSaving] = useState(false); const profiles = useMemo( () => (config ? resolveTerminalProfiles(config.terminalProfiles) : null), @@ -1347,38 +1230,28 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) { const saveProfiles = useCallback( async (next: TerminalProfile[]) => { - setIsSaving(true); - try { - await patchConfig({ terminalProfiles: next }); - } catch (error) { - Alert.alert( - t("common.errors.unableToSave"), - error instanceof Error ? error.message : String(error), - ); - } finally { - setIsSaving(false); - } + await patchConfig({ terminalProfiles: next }); }, - [patchConfig, t], + [patchConfig], ); const handleAddOpen = useCallback(() => setIsAdding(true), []); const handleAddClose = useCallback(() => setIsAdding(false), []); const handleAddSave = useCallback( - (draft: ProfileDraft) => { + async (draft: ProfileDraft) => { const current = profiles ? [...profiles] : []; const next: TerminalProfile[] = [ ...current, { id: generateProfileId(), - name: draft.name.trim(), - command: draft.command.trim(), + name: draft.name, + command: draft.command, args: parseArgsString(draft.args), }, ]; + await saveProfiles(next); setIsAdding(false); - void saveProfiles(next); }, [profiles, saveProfiles], ); @@ -1402,20 +1275,20 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) { const handleEditClose = useCallback(() => setEditingProfile(null), []); const handleEditSave = useCallback( - (draft: ProfileDraft) => { + async (draft: ProfileDraft) => { if (!editingProfile || !profiles) return; const next: TerminalProfile[] = profiles.map((p) => p.id === editingProfile.id ? { ...p, - name: draft.name.trim(), - command: draft.command.trim(), + name: draft.name, + command: draft.command, args: parseArgsString(draft.args), } : p, ); + await saveProfiles(next); setEditingProfile(null); - void saveProfiles(next); }, [editingProfile, profiles, saveProfiles], ); @@ -1432,9 +1305,16 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) { confirmLabel: t("settings.host.terminalProfiles.remove"), cancelLabel: t("common.actions.cancel"), destructive: true, - }).then((confirmed) => { + }).then(async (confirmed) => { if (!confirmed || !profiles) return; - void saveProfiles(profiles.filter((p) => p.id !== id)); + try { + await saveProfiles(profiles.filter((p) => p.id !== id)); + } catch (error) { + Alert.alert( + t("common.errors.unableToSave"), + error instanceof Error ? error.message : String(error), + ); + } return; }); }, @@ -1442,29 +1322,43 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) { ); const handleMoveUp = useCallback( - (id: string) => { + async (id: string) => { if (!profiles) return; const index = profiles.findIndex((p) => p.id === id); if (index <= 0) return; const next = [...profiles]; const [item] = next.splice(index, 1); next.splice(index - 1, 0, item); - void saveProfiles(next); + try { + await saveProfiles(next); + } catch (error) { + Alert.alert( + t("common.errors.unableToSave"), + error instanceof Error ? error.message : String(error), + ); + } }, - [profiles, saveProfiles], + [profiles, saveProfiles, t], ); const handleMoveDown = useCallback( - (id: string) => { + async (id: string) => { if (!profiles) return; const index = profiles.findIndex((p) => p.id === id); if (index < 0 || index >= profiles.length - 1) return; const next = [...profiles]; const [item] = next.splice(index, 1); next.splice(index + 1, 0, item); - void saveProfiles(next); + try { + await saveProfiles(next); + } catch (error) { + Alert.alert( + t("common.errors.unableToSave"), + error instanceof Error ? error.message : String(error), + ); + } }, - [profiles, saveProfiles], + [profiles, saveProfiles, t], ); const addButton = useMemo( @@ -1474,11 +1368,11 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) { size="sm" leftIcon={addProfileIcon} onPress={handleAddOpen} - disabled={isSaving || !isConnected || !profiles} + disabled={!isConnected || !profiles} testID="terminal-profiles-add-button" /> ), - [handleAddOpen, isSaving, isConnected, profiles], + [handleAddOpen, isConnected, profiles], ); if (!isConnected) { @@ -1530,6 +1424,7 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) { initialDraft={EMPTY_PROFILE_DRAFT} onClose={handleAddClose} onSave={handleAddSave} + testID="terminal-profile-edit-modal" /> {editingProfile ? ( @@ -1585,34 +1480,6 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({ }, })); -const profileModalStyles = StyleSheet.create((theme) => ({ - body: { - gap: theme.spacing[4], - paddingBottom: theme.spacing[2], - }, - fieldGroup: { - gap: theme.spacing[1.5], - }, - fieldLabel: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, - }, - fieldHint: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, - actions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - marginTop: theme.spacing[2], - }, - actionButton: { - flex: 1, - }, -})); - const styles = StyleSheet.create((theme) => ({ identityEditButton: { padding: theme.spacing[1], diff --git a/packages/app/src/screens/settings/terminal-profile-edit-modal.test.tsx b/packages/app/src/screens/settings/terminal-profile-edit-modal.test.tsx new file mode 100644 index 000000000..540615112 --- /dev/null +++ b/packages/app/src/screens/settings/terminal-profile-edit-modal.test.tsx @@ -0,0 +1,376 @@ +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TerminalProfileEditModal, type ProfileDraft } from "./terminal-profile-edit-modal"; + +const { theme } = vi.hoisted(() => ({ + theme: { + spacing: { 2: 8, 3: 12, 4: 16 }, + fontSize: { sm: 13, base: 15, xs: 11 }, + fontWeight: { medium: 500 }, + borderRadius: { lg: 8 }, + colors: { + surface2: "#222", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + palette: { red: { 300: "#f87171" } }, + }, + }, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("@/constants/platform", () => ({ + isWeb: true, + isNative: false, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const inputPropsByTestID = vi.hoisted(() => ({ + map: new Map void; onSubmitEditing?: () => void }>(), +})); + +vi.mock("@/components/adaptive-modal-sheet", async () => { + const ReactModule = await import("react"); + const AdaptiveModalSheet = ({ + visible, + title, + children, + onClose, + testID, + }: { + visible: boolean; + title: string; + children: React.ReactNode; + onClose: () => void; + testID?: string; + }) => { + if (!visible) return null; + return ReactModule.createElement( + "div", + { "data-testid": testID ?? "adaptive-modal-sheet", "data-modal-title": title }, + ReactModule.createElement( + "button", + { + type: "button", + "data-testid": "adaptive-modal-sheet-close", + onClick: onClose, + }, + "Close", + ), + children, + ); + }; + const AdaptiveTextInput = ReactModule.forwardRef>( + (props, ref) => { + const p = props as { + initialValue?: string; + defaultValue?: string; + editable?: boolean; + testID?: string; + onChangeText?: (next: string) => void; + onSubmitEditing?: () => void; + }; + if (p.testID) { + inputPropsByTestID.map.set(p.testID, { + onChangeText: p.onChangeText, + onSubmitEditing: p.onSubmitEditing, + }); + } + return ReactModule.createElement("input", { + ref, + defaultValue: p.initialValue ?? p.defaultValue ?? "", + disabled: p.editable === false, + "data-testid": p.testID, + onChange: (e: { target: { value: string } }) => p.onChangeText?.(e.target.value), + onKeyDown: (e: { key: string; preventDefault: () => void }) => { + if (e.key === "Enter") { + e.preventDefault(); + p.onSubmitEditing?.(); + } + }, + }); + }, + ); + return { AdaptiveModalSheet, AdaptiveTextInput }; +}); + +vi.mock("@/components/ui/button", async () => { + const ReactModule = await import("react"); + return { + Button: ({ + children, + onPress, + disabled, + testID, + }: { + children?: React.ReactNode; + onPress?: () => void; + disabled?: boolean; + testID?: string; + }) => + ReactModule.createElement( + "button", + { + type: "button", + "data-testid": testID, + disabled: disabled || undefined, + onClick: () => !disabled && onPress?.(), + }, + children, + ), + }; +}); + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + const dom = new JSDOM(""); + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", dom.window); + vi.stubGlobal("document", dom.window.document); + vi.stubGlobal("HTMLElement", dom.window.HTMLElement); + vi.stubGlobal("HTMLInputElement", dom.window.HTMLInputElement); + vi.stubGlobal("KeyboardEvent", dom.window.KeyboardEvent); + vi.stubGlobal("Node", dom.window.Node); + vi.stubGlobal("navigator", dom.window.navigator); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + vi.spyOn(HTMLElement.prototype, "focus").mockImplementation(() => {}); +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +interface RenderOptions { + visible?: boolean; + title?: string; + initialDraft?: ProfileDraft; + onClose?: () => void; + onSave?: (draft: ProfileDraft) => Promise; +} + +function renderModal(options: RenderOptions = {}): void { + const { + visible = true, + title = "Add terminal profile", + initialDraft = { name: "", command: "", args: "" }, + onClose = vi.fn(), + onSave = vi.fn().mockResolvedValue(undefined), + } = options; + act(() => { + root?.render( + , + ); + }); +} + +function queryInput(testID: string): HTMLInputElement | null { + return document.querySelector(`[data-testid="${testID}"]`); +} + +function querySubmit(): HTMLButtonElement | null { + return document.querySelector('[data-testid="terminal-profile-save-button"]'); +} + +function queryCancel(): HTMLButtonElement | null { + return document.querySelector( + '[data-testid="terminal-profile-cancel-button"]', + ); +} + +function queryError(testID: string): HTMLElement | null { + return document.querySelector(`[data-testid="${testID}"]`); +} + +function click(element: Element | null): void { + if (!element) throw new Error("Cannot click null element"); + act(() => { + element.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + }); +} + +function typeInto(testID: string, value: string): void { + act(() => { + const input = queryInput(testID); + if (!input) throw new Error(`Input ${testID} not found`); + input.value = value; + inputPropsByTestID.map.get(testID)?.onChangeText?.(value); + }); +} + +function pressEnter(testID: string): void { + act(() => { + inputPropsByTestID.map.get(testID)?.onSubmitEditing?.(); + }); +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("TerminalProfileEditModal", () => { + it("renders with the initial draft pre-filled", () => { + renderModal({ + initialDraft: { name: "Claude Code", command: "claude", args: "--skip" }, + }); + + expect(queryInput("terminal-profile-name-input")?.value).toBe("Claude Code"); + expect(queryInput("terminal-profile-command-input")?.value).toBe("claude"); + expect(queryInput("terminal-profile-args-input")?.value).toBe("--skip"); + }); + + it("calls onSave with trimmed values and closes on success", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const onClose = vi.fn(); + renderModal({ onSave, onClose }); + + typeInto("terminal-profile-name-input", " Codex "); + typeInto("terminal-profile-command-input", " codex "); + typeInto("terminal-profile-args-input", " --flag "); + click(querySubmit()); + await flush(); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith({ + name: "Codex", + command: "codex", + args: " --flag ", + }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("shows inline validation errors for empty required fields", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + renderModal({ onSave }); + + click(querySubmit()); + await flush(); + + expect(onSave).not.toHaveBeenCalled(); + expect(queryError("terminal-profile-name-field-error")?.textContent).toContain( + "settings.host.terminalProfiles.nameRequired", + ); + expect(queryError("terminal-profile-command-field-error")?.textContent).toContain( + "settings.host.terminalProfiles.commandRequired", + ); + }); + + it("clears validation errors when the user types", async () => { + renderModal(); + + click(querySubmit()); + await flush(); + expect(queryError("terminal-profile-name-field-error")).not.toBeNull(); + + typeInto("terminal-profile-name-input", "Codex"); + await flush(); + expect(queryError("terminal-profile-name-field-error")).toBeNull(); + }); + + it("disables save and cancel while onSave is pending", async () => { + let resolve: () => void = () => {}; + const onSave = vi.fn( + () => + new Promise((r) => { + resolve = r; + }), + ); + renderModal({ onSave }); + + typeInto("terminal-profile-name-input", "Codex"); + typeInto("terminal-profile-command-input", "codex"); + click(querySubmit()); + await flush(); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(querySubmit()?.disabled).toBe(true); + expect(queryCancel()?.disabled).toBe(true); + + await act(async () => { + resolve(); + await Promise.resolve(); + }); + }); + + it("keeps the modal open with an error when onSave rejects", async () => { + const onSave = vi.fn().mockRejectedValue(new Error("Server said no")); + const onClose = vi.fn(); + renderModal({ onSave, onClose }); + + typeInto("terminal-profile-name-input", "Codex"); + typeInto("terminal-profile-command-input", "codex"); + click(querySubmit()); + await flush(); + + expect(onClose).not.toHaveBeenCalled(); + expect( + document.querySelector('[data-testid="terminal-profile-submit-error"]') + ?.textContent, + ).toContain("Server said no"); + expect(querySubmit()?.disabled).toBe(false); + }); + + it("calls onClose when the cancel button is clicked", () => { + const onClose = vi.fn(); + const onSave = vi.fn().mockResolvedValue(undefined); + renderModal({ onClose, onSave }); + + click(queryCancel()); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("submits the form when Enter is pressed in the args field", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + renderModal({ onSave }); + + typeInto("terminal-profile-name-input", "Codex"); + typeInto("terminal-profile-command-input", "codex"); + pressEnter("terminal-profile-args-input"); + await flush(); + + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith({ + name: "Codex", + command: "codex", + args: "", + }); + }); +}); diff --git a/packages/app/src/screens/settings/terminal-profile-edit-modal.tsx b/packages/app/src/screens/settings/terminal-profile-edit-modal.tsx new file mode 100644 index 000000000..e3939fc62 --- /dev/null +++ b/packages/app/src/screens/settings/terminal-profile-edit-modal.tsx @@ -0,0 +1,259 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Text, TextInput, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { FormField, FormTextInput } from "@/components/ui/form-field"; + +export interface ProfileDraft { + name: string; + command: string; + args: string; +} + +interface FieldErrors { + name?: string; + command?: string; +} + +interface TerminalProfileEditModalProps { + visible: boolean; + title: string; + initialDraft: ProfileDraft; + onClose: () => void; + onSave: (draft: ProfileDraft) => Promise; + testID?: string; +} + +export function TerminalProfileEditModal({ + visible, + title, + initialDraft, + onClose, + onSave, + testID, +}: TerminalProfileEditModalProps) { + const { t } = useTranslation(); + const [name, setName] = useState(initialDraft.name); + const [command, setCommand] = useState(initialDraft.command); + const [args, setArgs] = useState(initialDraft.args); + const [fieldErrors, setFieldErrors] = useState({}); + const [submitError, setSubmitError] = useState(null); + const [isPending, setIsPending] = useState(false); + const nameInputRef = useRef(null); + const commandInputRef = useRef(null); + const argsInputRef = useRef(null); + + const handleNameChange = useCallback((value: string) => { + setName(value); + setFieldErrors((current) => ({ ...current, name: undefined })); + }, []); + + const handleCommandChange = useCallback((value: string) => { + setCommand(value); + setFieldErrors((current) => ({ ...current, command: undefined })); + }, []); + + const handleArgsChange = useCallback((value: string) => { + setArgs(value); + }, []); + + const sheetHeader = useMemo(() => ({ title }), [title]); + + useEffect(() => { + if (!visible) { + setIsPending(false); + return; + } + setName(initialDraft.name); + setCommand(initialDraft.command); + setArgs(initialDraft.args); + setFieldErrors({}); + setSubmitError(null); + setIsPending(false); + + const timeout = setTimeout(() => { + nameInputRef.current?.focus(); + }, 50); + return () => clearTimeout(timeout); + }, [visible, initialDraft.name, initialDraft.command, initialDraft.args]); + + const validate = useCallback((): boolean => { + const errors: FieldErrors = {}; + if (name.trim().length === 0) { + errors.name = t("settings.host.terminalProfiles.nameRequired"); + } + if (command.trim().length === 0) { + errors.command = t("settings.host.terminalProfiles.commandRequired"); + } + setFieldErrors(errors); + return Object.keys(errors).length === 0; + }, [command, name, t]); + + const handleSave = useCallback(async () => { + if (isPending) return; + setSubmitError(null); + + if (!validate()) { + return; + } + + setIsPending(true); + try { + await onSave({ name: name.trim(), command: command.trim(), args }); + onClose(); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : t("common.errors.unableToSave")); + } finally { + setIsPending(false); + } + }, [args, command, isPending, name, onClose, onSave, t, validate]); + + const handleCancel = useCallback(() => { + if (isPending) return; + onClose(); + }, [isPending, onClose]); + + const handleNameSubmit = useCallback(() => { + commandInputRef.current?.focus(); + }, []); + + const handleCommandSubmit = useCallback(() => { + argsInputRef.current?.focus(); + }, []); + + const handleArgsSubmit = useCallback(() => { + void handleSave(); + }, [handleSave]); + + const nameError = fieldErrors.name; + const commandError = fieldErrors.command; + + return ( + + + + + + + + + + + + + + + {submitError ? ( + + {submitError} + + ) : null} + + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + body: { + gap: theme.spacing[4], + paddingBottom: theme.spacing[2], + }, + actions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + marginTop: theme.spacing[2], + }, + actionButton: { + flex: 1, + }, + submitError: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.sm, + }, +}));