Files
prowler/ui/components/scans/schedule/scan-schedule-fields.tsx

313 lines
8.9 KiB
TypeScript

"use client";
import { format } from "date-fns";
import { CalendarClock } from "lucide-react";
import type { ReactNode } from "react";
import { Controller, type UseFormReturn, useWatch } from "react-hook-form";
import {
Badge,
Button,
Checkbox,
Field,
FieldLabel,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/shadcn";
import {
formatDayOfMonth,
formatScheduleHour,
getBrowserTimezone,
getNextScheduledRun,
} from "@/lib/schedules";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import {
SCHEDULE_FREQUENCY,
SCHEDULE_WEEKDAY_LABELS,
type ScheduleFormValues,
} from "@/types/schedules";
// The INTERVAL label is resolved at render time from the form's intervalHours.
const FREQUENCY_OPTIONS = [
{ value: SCHEDULE_FREQUENCY.DAILY, label: "Daily" },
{ value: SCHEDULE_FREQUENCY.INTERVAL, label: null },
{ value: SCHEDULE_FREQUENCY.WEEKLY, label: "Weekly" },
{ value: SCHEDULE_FREQUENCY.MONTHLY, label: "Monthly" },
] as const;
const WEEKDAY_OPTIONS = SCHEDULE_WEEKDAY_LABELS.map((label, value) => ({
value,
label,
}));
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, hour) => ({
value: hour,
label: formatScheduleHour(hour),
}));
const MONTH_DAY_OPTIONS = Array.from({ length: 28 }, (_, index) => index + 1);
interface ScanScheduleFieldsProps {
form: UseFormReturn<ScheduleFormValues>;
disabled?: boolean;
showLaunchInitialScan?: boolean;
showNextScheduledCopy?: boolean;
/** Rendered at the right of the "Scan Schedule" header row. */
headerAction?: ReactNode;
/**
* When false, the frequency is locked to `Daily` and the advanced cadences
* (interval/weekly/monthly) are disabled. Used for non-Cloud (OSS) accounts.
*/
canUseAdvancedSchedule?: boolean;
/** Render the "Available in Prowler Cloud" upsell badge in the header. */
showCloudUpgradeBadge?: boolean;
}
function NumberSelect({
label,
value,
values,
onChange,
disabled,
}: {
label: string;
value: number;
values: ReadonlyArray<{ value: number; label: string }>;
onChange: (value: number) => void;
disabled?: boolean;
}) {
return (
<Field>
<FieldLabel>{label}</FieldLabel>
<Select
value={String(value)}
onValueChange={(nextValue) => onChange(Number(nextValue))}
disabled={disabled}
>
<SelectTrigger aria-label={label}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{values.map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
function getScheduleSummary({
frequency,
intervalHours,
dayOfWeek,
dayOfMonth,
}: Pick<
ScheduleFormValues,
"frequency" | "intervalHours" | "dayOfWeek" | "dayOfMonth"
>) {
switch (frequency) {
case SCHEDULE_FREQUENCY.INTERVAL:
return `Every ${intervalHours} hours`;
case SCHEDULE_FREQUENCY.WEEKLY:
return `Weekly on ${SCHEDULE_WEEKDAY_LABELS[dayOfWeek] ?? SCHEDULE_WEEKDAY_LABELS[0]}`;
case SCHEDULE_FREQUENCY.MONTHLY:
return `Monthly on the ${formatDayOfMonth(dayOfMonth)}`;
default:
return "Daily";
}
}
export function ScanScheduleFields({
form,
disabled = false,
showLaunchInitialScan = false,
showNextScheduledCopy = false,
headerAction,
canUseAdvancedSchedule = true,
showCloudUpgradeBadge = false,
}: ScanScheduleFieldsProps) {
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
// useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization.
const control = form.control;
const [frequency, hour, dayOfWeek, dayOfMonth, intervalHours] = useWatch({
control,
name: ["frequency", "hour", "dayOfWeek", "dayOfMonth", "intervalHours"],
});
const timezone = getBrowserTimezone();
const scheduleSummary = getScheduleSummary({
frequency,
intervalHours,
dayOfWeek,
dayOfMonth,
});
const frequencyLabel = (option: (typeof FREQUENCY_OPTIONS)[number]) =>
option.label ?? `Every ${intervalHours} hours`;
// In OSS (non-Cloud) the advanced cadence and time are locked: `/schedules/daily`
// ignores them, so they are display-only with a Cloud upsell.
const advancedDisabled = disabled || !canUseAdvancedSchedule;
const cloudUpgradeBadge = showCloudUpgradeBadge ? (
<Button
type="button"
variant="bare"
size="link-xs"
aria-label="Explore advanced scheduling in Prowler Cloud"
onClick={() =>
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ADVANCED_SCHEDULING)
}
>
<Badge variant="cloud" size="sm">
Cloud
</Badge>
</Button>
) : null;
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CalendarClock className="text-text-neutral-primary size-5" />
<h3 className="text-text-neutral-primary text-sm font-medium">
Scan Schedule
</h3>
{cloudUpgradeBadge}
</div>
{headerAction}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Controller
control={form.control}
name="hour"
render={({ field }) => (
<NumberSelect
label="Scan Time"
value={field.value}
values={HOUR_OPTIONS}
onChange={field.onChange}
disabled={advancedDisabled}
/>
)}
/>
<Controller
control={form.control}
name="frequency"
render={({ field }) => (
<Field>
<FieldLabel>Repeats</FieldLabel>
<Select
value={
canUseAdvancedSchedule
? field.value
: SCHEDULE_FREQUENCY.DAILY
}
onValueChange={field.onChange}
disabled={advancedDisabled}
>
<SelectTrigger aria-label="Repeats">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQUENCY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{frequencyLabel(option)}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
</div>
{frequency === SCHEDULE_FREQUENCY.WEEKLY && (
<Controller
control={form.control}
name="dayOfWeek"
render={({ field }) => (
<NumberSelect
label="Day of week"
value={field.value}
values={WEEKDAY_OPTIONS}
onChange={field.onChange}
disabled={disabled}
/>
)}
/>
)}
{frequency === SCHEDULE_FREQUENCY.MONTHLY && (
<Controller
control={form.control}
name="dayOfMonth"
render={({ field }) => (
<NumberSelect
label="Day of month"
value={field.value}
values={MONTH_DAY_OPTIONS.map((day) => ({
value: day,
label: String(day),
}))}
onChange={field.onChange}
disabled={disabled}
/>
)}
/>
)}
{showNextScheduledCopy &&
(canUseAdvancedSchedule ? (
<p className="text-text-neutral-secondary text-sm">
{scheduleSummary}. The next scheduled scan will start on:{" "}
{format(
getNextScheduledRun(
{
frequency,
hour,
dayOfWeek,
dayOfMonth,
intervalHours,
launchInitialScan: false,
},
new Date(),
),
"MMM d, yyyy",
)}{" "}
@ {formatScheduleHour(hour)} {timezone}
</p>
) : (
<p className="text-text-neutral-secondary text-sm">
A daily scan will run automatically once the account is connected.
</p>
))}
{showLaunchInitialScan && (
<Controller
control={form.control}
name="launchInitialScan"
render={({ field }) => (
<label className="flex items-center gap-3 text-sm font-medium">
<Checkbox
checked={field.value}
onCheckedChange={(checked) => field.onChange(checked === true)}
disabled={disabled}
aria-label="Launch an initial scan now for immediate findings"
/>
<span>Launch an initial scan now for immediate findings</span>
</label>
)}
/>
)}
</div>
);
}