diff --git a/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts b/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts index 600e548c82..a6245f8e92 100644 --- a/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts +++ b/apps/presentation/dashboard/smoke/capability-configuration-smoke.ts @@ -8,6 +8,7 @@ const periodicReportEditor = { { key: "profile_preset" }, { key: "route_ref" }, { key: "timezone" }, + { key: "schedule", nullable: true }, ], }; @@ -65,3 +66,10 @@ for (const invalid of ['{', 'null', '[]', 'true', '{"schema_version":"injected"} } console.log("capability configuration projection and JSON boundary smoke: ok"); + +const schedule = { schema_version: "periodic_report_schedule_v0", schedule_id: "weekly", + rrule: "FREQ=WEEKLY;BYDAY=FR;BYHOUR=18;BYMINUTE=0", timezone: "Asia/Shanghai" }; +assert.deepEqual(projectEditableCapabilityConfiguration(periodicReportEditor, { schedule }), { schedule }); +assert.deepEqual(parseEditableCapabilityJson(periodicReportEditor, JSON.stringify({ schedule })), { schedule }); +assert.deepEqual(projectEditableCapabilityConfiguration(periodicReportEditor, { schedule: null }, { schedule }), { schedule: null }, + "explicit nullable clear must not restore the inherited schedule on editor mode changes"); diff --git a/apps/presentation/dashboard/src/data/capability-configuration.ts b/apps/presentation/dashboard/src/data/capability-configuration.ts index 4a7c2ba5c4..8b8ff2c3fe 100644 --- a/apps/presentation/dashboard/src/data/capability-configuration.ts +++ b/apps/presentation/dashboard/src/data/capability-configuration.ts @@ -1,5 +1,6 @@ export type CapabilityConfigurationFieldDescriptor = { key: string; + nullable?: boolean; }; export type CapabilityConfigurationEditorDescriptor = { @@ -27,10 +28,12 @@ export function projectEditableCapabilityConfiguration( ): Record { const primary = configurationObject(value); const defaults = configurationObject(fallback); - return Object.fromEntries(editor.fields.flatMap(({ key }) => { + return Object.fromEntries(editor.fields.flatMap(({ key, nullable }) => { const primaryValue = primary[key]; const defaultValue = defaults[key]; - if (Object.hasOwn(primary, key) && primaryValue != null) return [[key, primaryValue]]; + // Explicit null clears an optional value; it must not resurrect a default + // when switching between guided and JSON editors. + if (Object.hasOwn(primary, key) && (primaryValue != null || (nullable && primaryValue === null))) return [[key, primaryValue]]; if (Object.hasOwn(defaults, key) && defaultValue != null) return [[key, defaultValue]]; return []; })); diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index c250699bf7..43c1c20fa8 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -1057,6 +1057,13 @@ export async function configureGoalChannelAutoNotify(options: { autoNotify: bool ); } +export const periodicReportScheduleSchema = z.object({ + schema_version: z.literal("periodic_report_schedule_v0"), + schedule_id: z.string(), + rrule: z.string(), + timezone: z.string(), +}); + export const periodicReportMachineConfigurationSchema = z.object({ schema_version: z.literal("periodic_report_machine_defaults_v0"), enabled: z.boolean(), @@ -1064,6 +1071,7 @@ export const periodicReportMachineConfigurationSchema = z.object({ profile_preset: z.string().optional(), route_ref: z.string().optional(), timezone: z.string(), + schedule: periodicReportScheduleSchema.nullable().optional(), }); export const machineConfigurationSchema = z.object({ @@ -1089,7 +1097,8 @@ export const capabilityConfigurationFieldSchema = z.object({ key: z.string(), label: z.string(), description: z.string(), - input_kind: z.enum(["boolean", "number", "select", "string_list", "text"]), + input_kind: z.enum(["boolean", "number", "select", "string_list", "text", "periodic_report_schedule"]), + nullable: z.boolean().optional(), required: z.boolean(), minimum: z.number().int().optional(), maximum: z.number().int().optional(), diff --git a/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx b/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx index a3d975e447..b6733b302e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/capability-configuration-fields.tsx @@ -1,10 +1,11 @@ import { useId, type ReactNode } from "react"; import type { CapabilityConfigurationEditor } from "../../data/chat"; +import { PeriodicReportScheduleField } from "./periodic-report-schedule-field"; type FieldCopy = Record; type ConfigurationField = CapabilityConfigurationEditor["fields"][number]; -type FieldValue = boolean | number | string | string[]; +type FieldValue = boolean | number | string | string[] | Record | null; type FieldChange = (key: string, value: FieldValue) => void; type ConfigurationFieldProps = Readonly<{ @@ -13,12 +14,18 @@ type ConfigurationFieldProps = Readonly<{ id: string; onChange?: FieldChange; value: unknown; + timezone: string; }>; -function ConfigurationFieldControl({ copy, field, id, onChange, value }: ConfigurationFieldProps) { +function ConfigurationFieldControl({ copy, field, id, onChange, value, timezone }: ConfigurationFieldProps) { const label = copy[field.key]?.label ?? field.label; const readOnly = !onChange; + if (field.input_kind === "periodic_report_schedule") { + return onChange(field.key, schedule) : undefined} />; + } + if (field.input_kind === "boolean") { return (