refactor(app): improve terminal profile modal form and zinc button contrast

- Extract terminal profile add/edit modal into a reusable component with shared FormField/FormTextInput primitives.
- Add inline validation, save-pending state, and keyboard navigation.
- Fix zinc theme primary button foreground contrast by using accentForeground/destructiveForeground tokens.

Closes #1484
This commit is contained in:
Mohamed Boudra
2026-06-12 19:30:14 +08:00
committed by GitHub
parent 00a6e636fb
commit a3951495d4
13 changed files with 787 additions and 185 deletions
@@ -319,8 +319,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}, []);
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.palette.white} />,
[theme.colors.palette.white],
() => <Link2 size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
@@ -77,8 +77,8 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
}, []);
const pairIcon = useMemo(
() => <Link size={16} color={theme.colors.palette.white} />,
[theme.colors.palette.white],
() => <Link size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const handleClose = useCallback(() => {
+2 -2
View File
@@ -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,
@@ -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 (
<Text style={styles.errorText} testID={errorTestID}>
{error}
</Text>
);
}
if (hint) {
return (
<Text style={styles.hintText} testID={hintTestID}>
{hint}
</Text>
);
}
return null;
}, [error, hint, errorTestID, hintTestID]);
return (
<View style={styles.container} testID={testID}>
<Text style={styles.label}>{label}</Text>
{children}
{hintOrError}
</View>
);
}
export const FormTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(function FormTextInput(
{ style, ...props },
ref,
) {
const inputStyle = useMemo(() => [formInputStyles.input, style], [style]);
return <AdaptiveTextInput ref={ref} {...props} style={inputStyle} />;
});
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,
},
}));
+3
View File
@@ -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}}"?',
+3
View File
@@ -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}}"?',
+3
View File
@@ -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}}"?',
+3
View File
@@ -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}}"?',
+3
View File
@@ -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}}"?',
+3
View File
@@ -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}}"?',
+46 -179
View File
@@ -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<SheetHeader>(() => ({ 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 (
<AdaptiveModalSheet
visible={visible}
header={sheetHeader}
onClose={onClose}
testID="terminal-profile-edit-modal"
desktopMaxWidth={480}
>
<View style={profileModalStyles.body}>
<View style={profileModalStyles.fieldGroup}>
<Text style={profileModalStyles.fieldLabel}>
{t("settings.host.terminalProfiles.nameLabel")}
</Text>
<AdaptiveTextInput
initialValue={initialDraft.name}
resetKey={visible ? "open" : "closed"}
onChangeText={setName}
placeholder={t("settings.host.terminalProfiles.namePlaceholder")}
autoCapitalize="none"
autoCorrect={false}
testID="terminal-profile-name-input"
/>
</View>
<View style={profileModalStyles.fieldGroup}>
<Text style={profileModalStyles.fieldLabel}>
{t("settings.host.terminalProfiles.commandLabel")}
</Text>
<AdaptiveTextInput
initialValue={initialDraft.command}
resetKey={visible ? "open" : "closed"}
onChangeText={setCommand}
placeholder={t("settings.host.terminalProfiles.commandPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
testID="terminal-profile-command-input"
/>
</View>
<View style={profileModalStyles.fieldGroup}>
<Text style={profileModalStyles.fieldLabel}>
{t("settings.host.terminalProfiles.argsLabel")}
</Text>
<AdaptiveTextInput
initialValue={initialDraft.args}
resetKey={visible ? "open" : "closed"}
onChangeText={setArgs}
placeholder={t("settings.host.terminalProfiles.argsPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
testID="terminal-profile-args-input"
/>
<Text style={profileModalStyles.fieldHint}>
{t("settings.host.terminalProfiles.argsHint")}
</Text>
</View>
<View style={profileModalStyles.actions}>
<Button
variant="secondary"
size="sm"
style={profileModalStyles.actionButton}
onPress={onClose}
>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
size="sm"
style={profileModalStyles.actionButton}
onPress={handleSave}
disabled={!canSave}
testID="terminal-profile-save-button"
>
{t("settings.host.terminalProfiles.save")}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
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],
@@ -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<string, { onChangeText?: (next: string) => 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<HTMLInputElement, Record<string, unknown>>(
(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("<!doctype html><html><body></body></html>");
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<void>;
}
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(
<TerminalProfileEditModal
visible={visible}
title={title}
initialDraft={initialDraft}
onClose={onClose}
onSave={onSave}
testID="terminal-profile-edit-modal"
/>,
);
});
}
function queryInput(testID: string): HTMLInputElement | null {
return document.querySelector<HTMLInputElement>(`[data-testid="${testID}"]`);
}
function querySubmit(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>('[data-testid="terminal-profile-save-button"]');
}
function queryCancel(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>(
'[data-testid="terminal-profile-cancel-button"]',
);
}
function queryError(testID: string): HTMLElement | null {
return document.querySelector<HTMLElement>(`[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<void> {
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<void>((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<HTMLElement>('[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: "",
});
});
});
@@ -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<void>;
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<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const nameInputRef = useRef<TextInput>(null);
const commandInputRef = useRef<TextInput>(null);
const argsInputRef = useRef<TextInput>(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<SheetHeader>(() => ({ 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 (
<AdaptiveModalSheet
visible={visible}
header={sheetHeader}
onClose={handleCancel}
testID={testID}
desktopMaxWidth={480}
>
<View style={styles.body}>
<FormField
label={t("settings.host.terminalProfiles.nameLabel")}
error={nameError}
testID="terminal-profile-name-field"
>
<FormTextInput
ref={nameInputRef}
initialValue={initialDraft.name}
resetKey={visible ? "open" : "closed"}
onChangeText={handleNameChange}
placeholder={t("settings.host.terminalProfiles.namePlaceholder")}
autoCapitalize="none"
autoCorrect={false}
editable={!isPending}
returnKeyType="next"
onSubmitEditing={handleNameSubmit}
nativeID="terminal-profile-name-input"
accessibilityLabel={t("settings.host.terminalProfiles.nameLabel")}
testID="terminal-profile-name-input"
/>
</FormField>
<FormField
label={t("settings.host.terminalProfiles.commandLabel")}
error={commandError}
testID="terminal-profile-command-field"
>
<FormTextInput
ref={commandInputRef}
initialValue={initialDraft.command}
resetKey={visible ? "open" : "closed"}
onChangeText={handleCommandChange}
placeholder={t("settings.host.terminalProfiles.commandPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
editable={!isPending}
returnKeyType="next"
onSubmitEditing={handleCommandSubmit}
nativeID="terminal-profile-command-input"
accessibilityLabel={t("settings.host.terminalProfiles.commandLabel")}
testID="terminal-profile-command-input"
/>
</FormField>
<FormField
label={t("settings.host.terminalProfiles.argsLabel")}
hint={t("settings.host.terminalProfiles.argsHint")}
testID="terminal-profile-args-field"
>
<FormTextInput
ref={argsInputRef}
initialValue={initialDraft.args}
resetKey={visible ? "open" : "closed"}
onChangeText={handleArgsChange}
placeholder={t("settings.host.terminalProfiles.argsPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
editable={!isPending}
returnKeyType="done"
onSubmitEditing={handleArgsSubmit}
nativeID="terminal-profile-args-input"
accessibilityLabel={t("settings.host.terminalProfiles.argsLabel")}
testID="terminal-profile-args-input"
/>
</FormField>
{submitError ? (
<Text style={styles.submitError} testID="terminal-profile-submit-error">
{submitError}
</Text>
) : null}
<View style={styles.actions}>
<Button
variant="secondary"
style={styles.actionButton}
onPress={handleCancel}
disabled={isPending}
testID="terminal-profile-cancel-button"
>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
style={styles.actionButton}
onPress={handleSave}
disabled={isPending}
testID="terminal-profile-save-button"
>
{isPending
? t("settings.host.terminalProfiles.saving")
: t("settings.host.terminalProfiles.save")}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
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,
},
}));