Files
paseo/packages/app/src/components/schedules/cadence-editor.tsx
T
Mohamed BoudraandGitHub e2e97e4854 fix(app): preserve IME composition across text fields (#3517)
* fix(app): preserve IME composition in form inputs

Form model publication was replaying changing default text into the native or DOM editing surface. Keep the editing surface authoritative, defer web publication until composition commits, and reserve resetKey changes for explicit text replacement.

* refactor(app): centralize text editing authority

Route every app text field, including the composer and plugin runtime, through one platform-owned editing primitive. Its type and runtime reject controlled value replay, its command handle owns programmatic replacement, and lint prevents raw React Native TextInput imports outside the boundary.

* fix(app): restore composer placeholder styling
2026-08-18 11:32:49 +00:00

142 lines
4.6 KiB
TypeScript

import { useCallback, useMemo, useReducer, useState, type ReactNode } from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { ScheduleCadence } from "@getpaseo/protocol/schedule/types";
import type { FieldControlSize } from "@/components/ui/control-geometry";
import { Field, FormTextInput } from "@/components/ui/form-field";
import { SelectField, type SelectFieldOption } from "@/components/ui/select-field";
import {
CADENCE_PRESET_OPTIONS,
normalizeScheduleFormCadence,
resolveCronPresetDisplay,
resolveCronPresetId,
} from "@/schedules/schedule-cadence-options";
import { getDeviceTimeZone } from "@/utils/device-timezone";
import { describeCron, validateCron } from "@/utils/schedule-format";
type CronCadence = Extract<ScheduleCadence, { type: "cron" }>;
export interface CadenceEditorProps {
value: ScheduleCadence;
onChange: (next: ScheduleCadence) => void;
error?: string;
size?: FieldControlSize;
}
const PRESET_OPTIONS: SelectFieldOption<string>[] = CADENCE_PRESET_OPTIONS.map((option) => ({
id: option.id,
value: option.id,
label: option.label,
testID: `schedule-cadence-preset-${option.id}`,
}));
function getCronPreview(expression: string, timezone: string, error: string | null): string | null {
if (error || !expression) {
return null;
}
return describeCron({ type: "cron", expression, timezone }) ?? expression;
}
function buildCronCadence(expression: string, timezone: string): CronCadence {
return { type: "cron", expression, timezone };
}
export function CadenceEditor({ value, onChange, error, size = "md" }: CadenceEditorProps) {
const deviceTimeZone = useMemo(getDeviceTimeZone, []);
const normalizedValue = normalizeScheduleFormCadence(value, deviceTimeZone);
const [cronText, setCronText] = useState(() => normalizedValue.expression);
const [fieldResetKey, bumpFieldResetKey] = useReducer((key: number) => key + 1, 0);
const cronTimeZone = normalizedValue.timezone ?? deviceTimeZone;
const trimmedCron = cronText.trim();
const localCronError = trimmedCron ? validateCron(trimmedCron) : null;
const effectiveError = error ?? localCronError;
const preview = getCronPreview(trimmedCron, cronTimeZone, effectiveError ?? null);
const currentCadence = useMemo<CronCadence>(
() => buildCronCadence(trimmedCron, cronTimeZone),
[cronTimeZone, trimmedCron],
);
const selectedPresetId = resolveCronPresetId(currentCadence);
const selectedPresetDisplay = resolveCronPresetDisplay(currentCadence);
const handlePresetChange = useCallback(
(presetId: string) => {
const preset = CADENCE_PRESET_OPTIONS.find((option) => option.id === presetId);
if (!preset) {
return;
}
setCronText(preset.expression);
bumpFieldResetKey();
onChange(buildCronCadence(preset.expression, cronTimeZone));
},
[cronTimeZone, onChange],
);
const handleCronChange = useCallback(
(text: string) => {
setCronText(text);
onChange(buildCronCadence(text.trim(), cronTimeZone));
},
[cronTimeZone, onChange],
);
let feedback: ReactNode = null;
if (effectiveError) {
feedback = <Text style={styles.error}>{effectiveError}</Text>;
} else if (preview) {
feedback = <Text style={styles.preview}>{preview}</Text>;
}
return (
<Field label="Cadence">
<View style={styles.stack}>
<SelectField
label="Cadence"
value={selectedPresetId === "custom" ? null : selectedPresetId}
selectedDisplay={selectedPresetDisplay}
options={PRESET_OPTIONS}
onChange={handlePresetChange}
placeholder="Select cadence"
emptyText="No cadences found"
searchable={false}
title="Cadence"
size={size}
triggerTestID="schedule-cadence-preset-trigger"
field={false}
/>
<FormTextInput
size={size}
testID="cadence-cron-expression"
accessibilityLabel="Cron expression"
initialValue={cronText}
resetKey={`cadence-cron-${fieldResetKey}`}
onChangeText={handleCronChange}
placeholder="0 9 * * *"
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
style={styles.cronInput}
/>
{feedback}
</View>
</Field>
);
}
const styles = StyleSheet.create((theme) => ({
stack: {
gap: theme.spacing[3],
},
cronInput: {
fontFamily: theme.fontFamily.mono,
},
preview: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
},
error: {
fontSize: theme.fontSize.sm,
color: theme.colors.palette.red[300],
},
}));