From 92db5032b0e2fe2b95bf15c40c54bc04779a72f9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 1 Aug 2026 12:00:18 -0700 Subject: [PATCH] refactor(ui): consolidate cron form control rendering --- ui/src/pages/cron/view.test.ts | 31 +- ui/src/pages/cron/view.ts | 1047 ++++++++++++-------------------- 2 files changed, 398 insertions(+), 680 deletions(-) diff --git a/ui/src/pages/cron/view.test.ts b/ui/src/pages/cron/view.test.ts index ed361c19095b..06eb8edd6ac8 100644 --- a/ui/src/pages/cron/view.test.ts +++ b/ui/src/pages/cron/view.test.ts @@ -602,19 +602,38 @@ describe("cron view editor", () => { expect(onClosePanel).toHaveBeenCalledTimes(1); }); - it("wires form changes from prompt and name inputs", () => { + it("wires shared text and select controls without changing their field ownership", () => { const onFormChange = vi.fn(); - const container = renderView({ createOpen: true, onFormChange }); + const container = renderView({ + createOpen: true, + channels: ["telegram"], + channelMeta: [{ id: "telegram", label: "", detailLabel: "Telegram" }], + channelLabels: { telegram: "Telegram fallback" }, + form: { ...DEFAULT_CRON_FORM, scheduleKind: "cron", failureAlertMode: "custom" }, + onFormChange, + }); const prompt = getElement(container, "#cron-payload-text", HTMLTextAreaElement); prompt.value = "do the thing"; prompt.dispatchEvent(new Event("input", { bubbles: true })); expect(onFormChange).toHaveBeenCalledWith({ payloadText: "do the thing" }); - const name = getElement(container, "#cron-name", HTMLInputElement); - name.value = "Thing"; - name.dispatchEvent(new Event("input", { bubbles: true })); - expect(onFormChange).toHaveBeenCalledWith({ name: "Thing" }); + for (const field of ["name", "sessionKey", "deliveryAccountId", "payloadModel"] as const) { + const id = `cron-${field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; + const input = getElement(container, `#${id}`, HTMLInputElement); + if (field === "sessionKey" || field === "deliveryAccountId") { + expect(input.placeholder).toBe(field === "sessionKey" ? "agent:main:main" : "default"); + } + input.value = field; + input.dispatchEvent(new Event("input", { bubbles: true })); + expect(onFormChange).toHaveBeenLastCalledWith({ [field]: field }); + } + + const channel = getElement(container, "#cron-failure-alert-channel", HTMLSelectElement); + channel.value = "telegram"; + expect(channel.selectedOptions[0]?.textContent).toBe("Telegram fallback"); + channel.dispatchEvent(new Event("change", { bubbles: true })); + expect(onFormChange).toHaveBeenLastCalledWith({ failureAlertChannel: "telegram" }); }); it("switches schedule inputs by segmented kind and wires kind changes", () => { diff --git a/ui/src/pages/cron/view.ts b/ui/src/pages/cron/view.ts index cd32fb7ffbfb..d61454156a3a 100644 --- a/ui/src/pages/cron/view.ts +++ b/ui/src/pages/cron/view.ts @@ -133,40 +133,24 @@ type CronProps = { // ── Shared option helpers ── function buildChannelOptions(props: CronProps): string[] { - const options = ["last", ...props.channels.filter(Boolean)]; const current = props.form.deliveryChannel?.trim(); - if (current && !options.includes(current)) { - options.push(current); - } - const seen = new Set(); - return options.filter((value) => { - if (seen.has(value)) { - return false; - } - seen.add(value); - return true; - }); + return uniqueStrings(["last", ...props.channels.filter(Boolean), ...(current ? [current] : [])]); } function resolveChannelLabel(props: CronProps, channel: string): string { - if (channel === "last") { - return "last"; - } - const meta = props.channelMeta?.find((entry) => entry.id === channel); - if (meta?.label) { - return meta.label; - } - return props.channelLabels?.[channel] ?? channel; + return channel === "last" + ? channel + : props.channelMeta?.find((entry) => entry.id === channel)?.label || + (props.channelLabels?.[channel] ?? channel); } function renderSuggestionList(id: string, options: string[]) { const clean = uniqueStrings(normalizeStringEntries(options)); - if (clean.length === 0) { - return nothing; - } - return html` - ${clean.map((value) => html` `)} - `; + return clean.length === 0 + ? nothing + : html` + ${clean.map((value) => html` `)} + `; } // ── Validation summary helpers ── @@ -178,45 +162,27 @@ type BlockingField = { inputId: string; }; +const CRON_FIELD_LABEL_KEYS: Record = { + name: "cron.form.fieldName", + scheduleAt: "cron.form.runAt", + everyAmount: "cron.form.every", + cronExpr: "cron.form.expression", + staggerAmount: "cron.form.staggerWindow", + payloadText: "cron.form.assistantTaskPrompt", + payloadModel: "cron.form.model", + payloadThinking: "cron.form.thinking", + timeoutSeconds: "cron.form.timeoutSeconds", + deliveryTo: "cron.form.to", + failureAlertAfter: "cron.form.failureAlertAfter", + failureAlertCooldownSeconds: "cron.form.failureAlertCooldown", +}; + function errorIdForField(key: CronFieldKey) { return `cron-error-${key}`; } -function inputIdForField(key: CronFieldKey) { - if (key === "name") { - return "cron-name"; - } - if (key === "scheduleAt") { - return "cron-schedule-at"; - } - if (key === "everyAmount") { - return "cron-every-amount"; - } - if (key === "cronExpr") { - return "cron-cron-expr"; - } - if (key === "staggerAmount") { - return "cron-stagger-amount"; - } - if (key === "payloadText") { - return "cron-payload-text"; - } - if (key === "payloadModel") { - return "cron-payload-model"; - } - if (key === "payloadThinking") { - return "cron-payload-thinking"; - } - if (key === "timeoutSeconds") { - return "cron-timeout-seconds"; - } - if (key === "failureAlertAfter") { - return "cron-failure-alert-after"; - } - if (key === "failureAlertCooldownSeconds") { - return "cron-failure-alert-cooldown-seconds"; - } - return "cron-delivery-to"; +function inputIdForField(key: string) { + return `cron-${key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; } function fieldLabelForKey( @@ -224,29 +190,13 @@ function fieldLabelForKey( form: CronFormState, deliveryMode: CronFormState["deliveryMode"], ) { - if (key === "payloadText") { - return form.payloadKind === "systemEvent" - ? t("cron.form.mainTimelineMessage") - : t("cron.form.assistantTaskPrompt"); + if (key === "payloadText" && form.payloadKind === "systemEvent") { + return t("cron.form.mainTimelineMessage"); } - if (key === "deliveryTo") { - return deliveryMode === "webhook" ? t("cron.form.webhookUrl") : t("cron.form.to"); + if (key === "deliveryTo" && deliveryMode === "webhook") { + return t("cron.form.webhookUrl"); } - const labels: Record = { - name: t("cron.form.fieldName"), - scheduleAt: t("cron.form.runAt"), - everyAmount: t("cron.form.every"), - cronExpr: t("cron.form.expression"), - staggerAmount: t("cron.form.staggerWindow"), - payloadText: t("cron.form.assistantTaskPrompt"), - payloadModel: t("cron.form.model"), - payloadThinking: t("cron.form.thinking"), - timeoutSeconds: t("cron.form.timeoutSeconds"), - deliveryTo: t("cron.form.to"), - failureAlertAfter: t("cron.form.failureAlertAfter"), - failureAlertCooldownSeconds: t("cron.form.failureAlertCooldown"), - }; - return labels[key]; + return t(CRON_FIELD_LABEL_KEYS[key]); } function collectBlockingFields( @@ -254,34 +204,19 @@ function collectBlockingFields( form: CronFormState, deliveryMode: CronFormState["deliveryMode"], ): BlockingField[] { - const orderedKeys: CronFieldKey[] = [ - "name", - "scheduleAt", - "everyAmount", - "cronExpr", - "staggerAmount", - "payloadText", - "payloadModel", - "payloadThinking", - "timeoutSeconds", - "deliveryTo", - "failureAlertAfter", - "failureAlertCooldownSeconds", - ]; - const fields: BlockingField[] = []; - for (const key of orderedKeys) { + return (Object.keys(CRON_FIELD_LABEL_KEYS) as CronFieldKey[]).flatMap((key) => { const message = errors[key]; - if (!message) { - continue; - } - fields.push({ - key, - label: fieldLabelForKey(key, form, deliveryMode), - message, - inputId: inputIdForField(key), - }); - } - return fields; + return message + ? [ + { + key, + label: fieldLabelForKey(key, form, deliveryMode), + message, + inputId: inputIdForField(key), + }, + ] + : []; + }); } function focusFormField(id: string) { @@ -347,19 +282,122 @@ function renderFieldRow(params: { `; } -function renderToggleRow(params: { +type CronStringFormField = { + [Field in keyof CronFormState]: CronFormState[Field] extends string ? Field : never; +}[keyof CronFormState]; + +type CronBooleanFormField = { + [Field in keyof CronFormState]: CronFormState[Field] extends boolean ? Field : never; +}[keyof CronFormState]; + +type CronInputOptions = { label: string; - checked: boolean; help?: string; + placeholder?: string; + list?: string; + type?: string; + required?: boolean; disabled?: boolean; - onChange: (checked: boolean) => void; -}) { + mono?: boolean; + errorKey?: CronFieldKey; + describeError?: boolean; +}; + +function renderCronInput(props: CronProps, field: CronStringFormField, options: CronInputOptions) { + const error = options.errorKey ? props.fieldErrors[options.errorKey] : undefined; + const describedBy = + error && options.errorKey && options.describeError !== false + ? errorIdForField(options.errorKey) + : undefined; + return html` + + props.onFormChange({ [field]: (event.currentTarget as HTMLInputElement).value })} + /> + `; +} + +function renderCronInputField( + props: CronProps, + field: CronStringFormField, + options: CronInputOptions, +) { + const errorKey = options.errorKey; + return renderFieldRow({ + label: options.label, + controlId: inputIdForField(field), + required: options.required, + help: options.help, + error: errorKey ? props.fieldErrors[errorKey] : undefined, + errorId: errorKey ? errorIdForField(errorKey) : undefined, + control: renderCronInput(props, field, options), + }); +} + +type CronSelectOption = { value: string; label: string }; + +type CronSelectOptions = { + label: string; + options: readonly CronSelectOption[]; + help?: string; + value?: string; + disabled?: boolean; + standalone?: boolean; +}; + +function renderCronSelect( + props: CronProps, + field: CronStringFormField, + options: CronSelectOptions, +) { + return html` + + `; +} + +function renderCronSelectField( + props: CronProps, + field: CronStringFormField, + options: CronSelectOptions, +) { + return renderFieldRow({ + label: options.label, + controlId: inputIdForField(field), + help: options.help, + control: renderCronSelect(props, field, options), + }); +} + +function renderToggleRow( + props: CronProps, + field: CronBooleanFormField, + params: { label: string; help?: string }, +) { return renderSettingsToggleRow({ title: params.label, description: params.help, - checked: params.checked, - disabled: params.disabled, - onChange: params.onChange, + checked: props.form[field], + onChange: (checked) => props.onFormChange({ [field]: checked }), }); } @@ -517,6 +555,32 @@ function renderToolbar(props: CronProps, hasAdvancedJobsFilters: boolean) { `; } +function renderJobsFilter( + props: CronProps, + field: keyof Parameters[0], + params: { + label: string; + value: string; + options: readonly CronSelectOption[]; + testId?: string; + }, +) { + return html` + + `; +} + function renderJobsFilterPopover(props: CronProps, active: boolean) { return html`