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:
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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}}"?',
|
||||
|
||||
@@ -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}}"?',
|
||||
|
||||
@@ -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}}"?',
|
||||
|
||||
@@ -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}}"?',
|
||||
|
||||
@@ -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}}"?',
|
||||
|
||||
@@ -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}}"?',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user