Treat the appearance setting as the rendered base size so the type ramp scales predictably. Native starts one pixel larger while web and desktop preserve the authored 14px base, with legacy values migrated on load.
143 lines
4.7 KiB
TypeScript
143 lines
4.7 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}`}
|
|
value={cronText}
|
|
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],
|
|
},
|
|
}));
|