mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
refactor(automations): enhance TriggerCard component with improved scheduling options, including frequency selection and custom cron input
This commit is contained in:
parent
282c0495c0
commit
2ba30837a9
3 changed files with 180 additions and 28 deletions
|
|
@ -1,9 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { AlertCircle, MoreHorizontal, Pencil, Save, Trash2 } from "lucide-react";
|
import { AlertCircle, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
import { updateTriggerMutationAtom } from "@/atoms/automations/automations-mutation.atoms";
|
||||||
import { JsonView } from "@/components/json-view";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -12,11 +11,26 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { type Trigger, triggerUpdateRequest } from "@/contracts/types/automation.types";
|
import { type Trigger, triggerUpdateRequest } from "@/contracts/types/automation.types";
|
||||||
import { describeCron } from "@/lib/automations/describe-cron";
|
import { describeCron } from "@/lib/automations/describe-cron";
|
||||||
import { formatRelativeFutureDate } from "@/lib/format-date";
|
import { formatRelativeFutureDate } from "@/lib/format-date";
|
||||||
|
import {
|
||||||
|
DEFAULT_SCHEDULE,
|
||||||
|
fromCron,
|
||||||
|
type ScheduleFrequency,
|
||||||
|
toCron,
|
||||||
|
} from "@/lib/automations/schedule-builder";
|
||||||
|
import { TimezoneCombobox } from "../../components/builder/timezone-combobox";
|
||||||
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
|
import { DeleteTriggerDialog } from "./delete-trigger-dialog";
|
||||||
|
|
||||||
interface TriggerCardProps {
|
interface TriggerCardProps {
|
||||||
|
|
@ -26,26 +40,58 @@ interface TriggerCardProps {
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SimpleFrequency = Extract<ScheduleFrequency, "hourly" | "daily" | "weekdays"> | "custom";
|
||||||
|
|
||||||
interface TriggerDraft {
|
interface TriggerDraft {
|
||||||
params: Record<string, unknown>;
|
frequency: SimpleFrequency;
|
||||||
static_inputs: Record<string, unknown>;
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
timezone: string;
|
||||||
|
cron: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SIMPLE_FREQUENCIES = new Set<ScheduleFrequency>(["hourly", "daily", "weekdays"]);
|
||||||
|
|
||||||
function draftFromTrigger(trigger: Trigger): TriggerDraft {
|
function draftFromTrigger(trigger: Trigger): TriggerDraft {
|
||||||
|
const cron = typeof trigger.params.cron === "string" ? trigger.params.cron : "";
|
||||||
|
const timezone = typeof trigger.params.timezone === "string" ? trigger.params.timezone : "UTC";
|
||||||
|
const model = fromCron(cron);
|
||||||
|
if (model && SIMPLE_FREQUENCIES.has(model.frequency)) {
|
||||||
|
return {
|
||||||
|
frequency: model.frequency as SimpleFrequency,
|
||||||
|
hour: model.hour,
|
||||||
|
minute: model.minute,
|
||||||
|
timezone,
|
||||||
|
cron,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
params: trigger.params,
|
frequency: "custom",
|
||||||
static_inputs: trigger.static_inputs ?? {},
|
hour: DEFAULT_SCHEDULE.hour,
|
||||||
|
minute: DEFAULT_SCHEDULE.minute,
|
||||||
|
timezone,
|
||||||
|
cron,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pad(value: number): string {
|
||||||
|
return value.toString().padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInt(raw: string, min: number, max: number): number {
|
||||||
|
const value = Number.parseInt(raw, 10);
|
||||||
|
if (Number.isNaN(value)) return min;
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One trigger row in the Triggers section of the detail page. Renders:
|
* One trigger row in the Triggers section of the detail page. Renders:
|
||||||
* - human-readable schedule
|
* - human-readable schedule
|
||||||
* - compact enable toggle
|
* - compact enable toggle
|
||||||
* - dropdown actions for edit/remove
|
* - dropdown actions for edit/remove
|
||||||
*
|
*
|
||||||
* Inline edit covers ``params`` and ``static_inputs`` — the two fields the
|
* Inline edit keeps schedule editing intentionally small: common frequencies,
|
||||||
* backend ``PATCH /triggers/[id]`` endpoint accepts beyond ``enabled``.
|
* time, timezone, and raw cron only for schedules outside the simple model.
|
||||||
* ``enabled`` stays on the Switch so the two surfaces don't fight.
|
* ``enabled`` stays on the Switch so the two surfaces don't fight.
|
||||||
*/
|
*/
|
||||||
export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: TriggerCardProps) {
|
export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: TriggerCardProps) {
|
||||||
|
|
@ -82,7 +128,22 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
||||||
|
|
||||||
async function saveEdit() {
|
async function saveEdit() {
|
||||||
setIssues([]);
|
setIssues([]);
|
||||||
const result = triggerUpdateRequest.safeParse(draft);
|
const params =
|
||||||
|
draft.frequency === "custom"
|
||||||
|
? { cron: draft.cron.trim(), timezone: draft.timezone }
|
||||||
|
: {
|
||||||
|
cron: toCron({
|
||||||
|
...DEFAULT_SCHEDULE,
|
||||||
|
frequency: draft.frequency,
|
||||||
|
hour: draft.hour,
|
||||||
|
minute: draft.minute,
|
||||||
|
}),
|
||||||
|
timezone: draft.timezone,
|
||||||
|
};
|
||||||
|
const result = triggerUpdateRequest.safeParse({
|
||||||
|
params,
|
||||||
|
static_inputs: trigger.static_inputs ?? {},
|
||||||
|
});
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
setIssues(
|
setIssues(
|
||||||
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
result.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
||||||
|
|
@ -169,13 +230,94 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
||||||
|
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<div className="space-y-3 border-t border-border/60 px-4 py-3 text-xs">
|
<div className="space-y-3 border-t border-border/60 px-4 py-3 text-xs">
|
||||||
<div className="rounded-md border border-input bg-background px-3 py-2 max-h-[24rem] overflow-auto">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<JsonView
|
<div className="space-y-1.5">
|
||||||
src={draft}
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="trigger-runs">
|
||||||
editable
|
Runs
|
||||||
onChange={(next) => setDraft(next as TriggerDraft)}
|
</label>
|
||||||
collapsed={false}
|
<Select
|
||||||
/>
|
value={draft.frequency}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setDraft((prev) => ({ ...prev, frequency: value as SimpleFrequency }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="trigger-runs" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="hourly">Every hour</SelectItem>
|
||||||
|
<SelectItem value="daily">Daily</SelectItem>
|
||||||
|
<SelectItem value="weekdays">Weekdays</SelectItem>
|
||||||
|
<SelectItem value="custom">Custom cron</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{draft.frequency === "hourly" ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label
|
||||||
|
className="text-xs font-medium text-muted-foreground"
|
||||||
|
htmlFor="trigger-minute"
|
||||||
|
>
|
||||||
|
At minute
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="trigger-minute"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={59}
|
||||||
|
value={draft.minute}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
minute: clampInt(event.target.value, 0, 59),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : draft.frequency !== "custom" ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="trigger-time">
|
||||||
|
Time
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="trigger-time"
|
||||||
|
type="time"
|
||||||
|
value={`${pad(draft.hour)}:${pad(draft.minute)}`}
|
||||||
|
onChange={(event) => {
|
||||||
|
const [hour, minute] = event.target.value.split(":");
|
||||||
|
setDraft((prev) => ({
|
||||||
|
...prev,
|
||||||
|
hour: clampInt(hour, 0, 23),
|
||||||
|
minute: clampInt(minute, 0, 59),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground" htmlFor="trigger-cron">
|
||||||
|
Schedule expression
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="trigger-cron"
|
||||||
|
value={draft.cron}
|
||||||
|
placeholder="0 9 * * 1-5"
|
||||||
|
className="font-mono"
|
||||||
|
onChange={(event) =>
|
||||||
|
setDraft((prev) => ({ ...prev, cron: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5 sm:col-span-2">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">Timezone</div>
|
||||||
|
<TimezoneCombobox
|
||||||
|
value={draft.timezone}
|
||||||
|
onChange={(timezone) => setDraft((prev) => ({ ...prev, timezone }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{issues.length > 0 && (
|
{issues.length > 0 && (
|
||||||
|
|
@ -205,11 +347,7 @@ export function TriggerCard({ trigger, automationId, canUpdate, canDelete }: Tri
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" size="sm" onClick={saveEdit} disabled={updating}>
|
<Button type="button" size="sm" onClick={saveEdit} disabled={updating}>
|
||||||
{updating ? (
|
{updating ? <Spinner size="xs" className="mr-1.5" /> : null}
|
||||||
<Spinner size="xs" className="mr-1.5" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,17 @@ export function AutomationEditContent({ searchSpaceId, automationId }: Automatio
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<AutomationBuilderForm
|
||||||
<AutomationEditHeader automation={automation} searchSpaceId={searchSpaceId} />
|
mode="edit"
|
||||||
<AutomationBuilderForm mode="edit" searchSpaceId={searchSpaceId} automation={automation} />
|
searchSpaceId={searchSpaceId}
|
||||||
</>
|
automation={automation}
|
||||||
|
renderModeSwitcher={(modeSwitcher) => (
|
||||||
|
<AutomationEditHeader
|
||||||
|
automation={automation}
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
|
modeSwitcher={modeSwitcher}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
"use client";
|
"use client";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Automation } from "@/contracts/types/automation.types";
|
import type { Automation } from "@/contracts/types/automation.types";
|
||||||
|
|
||||||
interface AutomationEditHeaderProps {
|
interface AutomationEditHeaderProps {
|
||||||
automation: Automation;
|
automation: Automation;
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
|
modeSwitcher?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutomationEditHeader({ automation, searchSpaceId }: AutomationEditHeaderProps) {
|
export function AutomationEditHeader({
|
||||||
|
automation,
|
||||||
|
searchSpaceId,
|
||||||
|
modeSwitcher,
|
||||||
|
}: AutomationEditHeaderProps) {
|
||||||
const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
|
const detailHref = `/dashboard/${searchSpaceId}/automations/${automation.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -20,11 +26,11 @@ export function AutomationEditHeader({ automation, searchSpaceId }: AutomationEd
|
||||||
Back to automation
|
Back to automation
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 className="text-xl md:text-2xl font-semibold text-foreground wrap-break-word">
|
<h1 className="text-xl md:text-2xl font-semibold text-foreground wrap-break-word">
|
||||||
Edit automation
|
Edit automation
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{automation.name}</p>
|
{modeSwitcher ? <div className="ml-auto">{modeSwitcher}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue