chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
import { getFormProps, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { EnvelopeIcon } from "@heroicons/react/20/solid";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { InputNumberStepper } from "~/components/primitives/InputNumberStepper";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { SpinnerWhite } from "~/components/primitives/Spinner";
|
||||
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
|
||||
import { PurchaseSchema } from "~/routes/resources.orgs.$organizationSlug.schedules-addon";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
|
||||
|
||||
export type SchedulePricing = {
|
||||
stepSize: number;
|
||||
centsPerStep: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
/** Action URL the purchase form posts to. */
|
||||
actionPath: string;
|
||||
schedulePricing: SchedulePricing;
|
||||
extraSchedules: number;
|
||||
usedSchedules: number;
|
||||
maxQuota: number;
|
||||
planScheduleLimit: number;
|
||||
triggerButton?: ReactNode;
|
||||
};
|
||||
|
||||
export function PurchaseSchedulesModal({
|
||||
actionPath,
|
||||
schedulePricing,
|
||||
extraSchedules,
|
||||
usedSchedules,
|
||||
maxQuota,
|
||||
planScheduleLimit,
|
||||
triggerButton,
|
||||
}: Props) {
|
||||
const showSelfServe = useShowSelfServe();
|
||||
const fetcher = useFetcher();
|
||||
const lastSubmission =
|
||||
fetcher.data && typeof fetcher.data === "object" && "status" in fetcher.data
|
||||
? fetcher.data
|
||||
: undefined;
|
||||
const [form, { amount }] = useForm({
|
||||
id: "purchase-schedules",
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: PurchaseSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
const stepSize = schedulePricing.stepSize;
|
||||
const [bundles, setBundles] = useState(Math.round(extraSchedules / stepSize));
|
||||
const amountValue = bundles * stepSize;
|
||||
const isLoading = fetcher.state !== "idle";
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
// Reset the bundle stepper to the user's current extra-schedules count on
|
||||
// each open. Earlier this only re-synced when `extraSchedules`/`stepSize`
|
||||
// props changed, so if the user opened the modal, typed a value, cancelled,
|
||||
// and reopened without purchasing, the stale draft persisted.
|
||||
useEffect(() => {
|
||||
if (open) setBundles(Math.round(extraSchedules / stepSize));
|
||||
}, [open, extraSchedules, stepSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const data = fetcher.data;
|
||||
if (
|
||||
fetcher.state === "idle" &&
|
||||
data !== null &&
|
||||
typeof data === "object" &&
|
||||
"ok" in data &&
|
||||
data.ok
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [fetcher.state, fetcher.data]);
|
||||
|
||||
const state = updateScheduleState({
|
||||
value: amountValue,
|
||||
existingValue: extraSchedules,
|
||||
quota: maxQuota,
|
||||
usedSchedules,
|
||||
planScheduleLimit,
|
||||
});
|
||||
const changeClassName =
|
||||
state === "decrease" ? "text-error" : state === "increase" ? "text-success" : undefined;
|
||||
|
||||
const pricePerSchedule = schedulePricing.centsPerStep / stepSize / 100;
|
||||
const pricePerStep = schedulePricing.centsPerStep / 100;
|
||||
const stepUnit = formatNumber(stepSize);
|
||||
const title = extraSchedules === 0 ? "Purchase extra schedules…" : "Add/remove extra schedules…";
|
||||
|
||||
if (!showSelfServe) {
|
||||
return (
|
||||
<Feedback
|
||||
defaultValue="enterprise"
|
||||
button={<Button variant="secondary/small">Request more</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{triggerButton ?? (
|
||||
<Button variant="primary/small" onClick={() => setOpen(true)}>
|
||||
{title}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>{title}</DialogHeader>
|
||||
<fetcher.Form method="post" action={actionPath} {...getFormProps(form)}>
|
||||
<div className="flex flex-col gap-4 pt-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Paragraph variant="small/bright">
|
||||
Schedules are purchased in bundles of {stepUnit}, at{" "}
|
||||
{formatCurrency(pricePerStep, false)}/month per bundle. Reducing will take effect at
|
||||
the start of the next billing cycle (1st of the month).
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Fieldset>
|
||||
<InputGroup fullWidth>
|
||||
<Label htmlFor="schedule-bundles" className="text-text-dimmed">
|
||||
Bundles of {stepUnit} schedules
|
||||
</Label>
|
||||
<InputNumberStepper
|
||||
id="schedule-bundles"
|
||||
step={1}
|
||||
min={0}
|
||||
value={bundles}
|
||||
onChange={(e) => setBundles(Number(e.target.value))}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<input type="hidden" name="amount" value={amountValue} />
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
{formatNumber(bundles)} {bundles === 1 ? "bundle" : "bundles"} ={" "}
|
||||
{formatNumber(amountValue)} schedules
|
||||
</Paragraph>
|
||||
<FormError id={amount.errorId}>{amount.errors}</FormError>
|
||||
<FormError>{form.errors}</FormError>
|
||||
</InputGroup>
|
||||
</Fieldset>
|
||||
{state === "need_to_delete" ? (
|
||||
<div className="flex flex-col pb-3">
|
||||
<Paragraph variant="small" className="text-warning" spacing>
|
||||
You need to delete{" "}
|
||||
{formatNumber(usedSchedules - (planScheduleLimit + amountValue))} more{" "}
|
||||
{usedSchedules - (planScheduleLimit + amountValue) === 1
|
||||
? "schedule"
|
||||
: "schedules"}{" "}
|
||||
before you can reduce to this level.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : state === "above_quota" ? (
|
||||
<div className="flex flex-col pb-3">
|
||||
<Paragraph variant="small" className="text-warning" spacing>
|
||||
Currently you can only have up to {formatNumber(maxQuota)} extra schedules. Send a
|
||||
request below to lift your current limit. We'll get back to you soon.
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pb-3 tabular-nums">
|
||||
<div className="grid grid-cols-2 border-b border-grid-dimmed pb-1">
|
||||
<Header3 className="font-normal text-text-dimmed">Summary</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-dimmed">Total</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className="pb-0 font-normal text-text-dimmed">
|
||||
<span className="text-text-bright">{formatNumber(extraSchedules)}</span> current
|
||||
extra
|
||||
</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-bright">
|
||||
{formatCurrency(extraSchedules * pricePerSchedule, true)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({formatNumber(extraSchedules)}{" "}
|
||||
{extraSchedules === 1 ? "schedule" : "schedules"})
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className={cn("pb-0 font-normal", changeClassName)}>
|
||||
{state === "increase" ? "+" : null}
|
||||
{formatNumber(amountValue - extraSchedules)}
|
||||
</Header3>
|
||||
<Header3 className={cn("justify-self-end font-normal", changeClassName)}>
|
||||
{state === "increase" ? "+" : null}
|
||||
{formatCurrency((amountValue - extraSchedules) * pricePerSchedule, true)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({formatNumber(Math.abs(amountValue - extraSchedules))}{" "}
|
||||
{Math.abs(amountValue - extraSchedules) === 1 ? "schedule" : "schedules"} @{" "}
|
||||
{formatCurrency(pricePerStep, false)}/mth per {stepUnit})
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 pt-2">
|
||||
<Header3 className="pb-0 font-normal text-text-dimmed">
|
||||
<span className="text-text-bright">{formatNumber(amountValue)}</span> new total
|
||||
</Header3>
|
||||
<Header3 className="justify-self-end font-normal text-text-bright">
|
||||
{formatCurrency(amountValue * pricePerSchedule, true)}
|
||||
</Header3>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 text-xs">
|
||||
<span className="text-text-dimmed">
|
||||
({formatNumber(amountValue)} {amountValue === 1 ? "schedule" : "schedules"})
|
||||
</span>
|
||||
<span className="justify-self-end text-text-dimmed">/mth</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
state === "above_quota" ? (
|
||||
<>
|
||||
<input type="hidden" name="action" value="quota-increase" />
|
||||
<Button
|
||||
LeadingIcon={isLoading ? SpinnerWhite : EnvelopeIcon}
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Send request for ${formatNumber(
|
||||
amountValue
|
||||
)}`}</span>
|
||||
</Button>
|
||||
</>
|
||||
) : state === "decrease" || state === "need_to_delete" ? (
|
||||
<>
|
||||
<input type="hidden" name="action" value="purchase" />
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
type="submit"
|
||||
disabled={isLoading || state === "need_to_delete"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Remove ${formatNumber(
|
||||
extraSchedules - amountValue
|
||||
)} ${extraSchedules - amountValue === 1 ? "schedule" : "schedules"}`}</span>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input type="hidden" name="action" value="purchase" />
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
disabled={isLoading || state === "no_change"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Purchase ${formatNumber(
|
||||
amountValue - extraSchedules
|
||||
)} ${amountValue - extraSchedules === 1 ? "schedule" : "schedules"}`}</span>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary/medium" disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</fetcher.Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function updateScheduleState({
|
||||
value,
|
||||
existingValue,
|
||||
quota,
|
||||
usedSchedules,
|
||||
planScheduleLimit,
|
||||
}: {
|
||||
value: number;
|
||||
existingValue: number;
|
||||
quota: number;
|
||||
usedSchedules: number;
|
||||
planScheduleLimit: number;
|
||||
}): "no_change" | "increase" | "decrease" | "above_quota" | "need_to_delete" {
|
||||
if (value === existingValue) return "no_change";
|
||||
if (value < existingValue) {
|
||||
const newTotalLimit = planScheduleLimit + value;
|
||||
if (usedSchedules > newTotalLimit) {
|
||||
return "need_to_delete";
|
||||
}
|
||||
return "decrease";
|
||||
}
|
||||
if (value > quota) return "above_quota";
|
||||
return "increase";
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import {
|
||||
BoltIcon,
|
||||
BoltSlashIcon,
|
||||
BookOpenIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
import { type FetcherWithComponents, Form, useLocation } from "@remix-run/react";
|
||||
import { type ReactNode } from "react";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
|
||||
import { ScheduleTypeCombo } from "~/components/runs/v3/ScheduleType";
|
||||
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { v3EditSchedulePath } from "~/utils/pathBuilder";
|
||||
|
||||
type RunRow = React.ComponentProps<typeof TaskRunsTable>["runs"][number];
|
||||
|
||||
type EnvironmentRow = React.ComponentProps<typeof EnvironmentCombo>["environment"] & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type ScheduleInspectorData = {
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
type: "DECLARATIVE" | "IMPERATIVE";
|
||||
taskIdentifier: string;
|
||||
cron: string;
|
||||
cronDescription: string;
|
||||
timezone: string;
|
||||
externalId: string | null;
|
||||
deduplicationKey: string | null;
|
||||
userProvidedDeduplicationKey: boolean;
|
||||
active: boolean;
|
||||
environments: EnvironmentRow[];
|
||||
runs: RunRow[];
|
||||
nextRuns: Date[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
schedule: ScheduleInspectorData;
|
||||
/**
|
||||
* Right-aligned slot in the header (e.g. a back link or close button).
|
||||
*/
|
||||
headerActions?: ReactNode;
|
||||
/**
|
||||
* URL the action `Form`s post to. Defaults to the current page (Form's
|
||||
* default behavior) — pass the schedule detail route when the inspector
|
||||
* is rendered somewhere else (e.g. in a sheet on a different page).
|
||||
*/
|
||||
actionPath?: string;
|
||||
/** When set, Edit calls back instead of navigating to the standalone edit page. */
|
||||
onEdit?: () => void;
|
||||
/** Submits enable/disable via this fetcher with `_format=json` so the host stays put. */
|
||||
activeToggleFetcher?: FetcherWithComponents<unknown>;
|
||||
/** Submits delete via this fetcher with `_format=json` so the host stays put. */
|
||||
deleteFetcher?: FetcherWithComponents<unknown>;
|
||||
};
|
||||
|
||||
export function ScheduleInspector({
|
||||
schedule,
|
||||
headerActions,
|
||||
actionPath,
|
||||
onEdit,
|
||||
activeToggleFetcher,
|
||||
deleteFetcher,
|
||||
}: Props) {
|
||||
const location = useLocation();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const isUtc = schedule.timezone === "UTC";
|
||||
const isImperative = schedule.type === "IMPERATIVE";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full max-h-full overflow-hidden bg-background-bright",
|
||||
isImperative ? "grid-rows-[2.5rem_1fr_auto]" : "grid-rows-[2.5rem_1fr]"
|
||||
)}
|
||||
>
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<Header2 className="whitespace-nowrap">{schedule.friendlyId}</Header2>
|
||||
{headerActions}
|
||||
</div>
|
||||
<div className="overflow-y-scroll scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div className="space-y-3">
|
||||
<div className="p-3">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Schedule ID</Property.Label>
|
||||
<Property.Value>{schedule.friendlyId}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Task ID</Property.Label>
|
||||
<Property.Value>{schedule.taskIdentifier}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Type</Property.Label>
|
||||
<Property.Value>
|
||||
<ScheduleTypeCombo type={schedule.type} className="text-sm" />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>CRON</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="space-y-2">
|
||||
<InlineCode variant="extra-small">{schedule.cron}</InlineCode>
|
||||
<Paragraph variant="small">{schedule.cronDescription}</Paragraph>
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Timezone</Property.Label>
|
||||
<Property.Value>{schedule.timezone}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item className="gap-1">
|
||||
<Property.Label>Environment</Property.Label>
|
||||
<Property.Value>
|
||||
<div className="flex flex-col gap-2">
|
||||
{schedule.environments.map((env) => (
|
||||
<EnvironmentCombo key={env.id} environment={env} className="text-xs" />
|
||||
))}
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{isImperative && (
|
||||
<>
|
||||
<Property.Item>
|
||||
<Property.Label>External ID</Property.Label>
|
||||
<Property.Value>
|
||||
{schedule.externalId ? schedule.externalId : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Deduplication key</Property.Label>
|
||||
<Property.Value>
|
||||
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : "–"}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item className="gap-1.5">
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<EnabledStatus enabled={schedule.active} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</>
|
||||
)}
|
||||
</Property.Table>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Header3 className="pb-1 pl-3">Last 5 runs</Header3>
|
||||
<TaskRunsTable
|
||||
total={schedule.runs.length}
|
||||
hasFilters={false}
|
||||
filters={{
|
||||
tasks: [],
|
||||
versions: [],
|
||||
statuses: [],
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
}}
|
||||
runs={schedule.runs}
|
||||
isLoading={false}
|
||||
variant="bright"
|
||||
disableAdjacentRows
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 pt-2">
|
||||
<Header3 className="pb-1 pl-3">Next 5 runs</Header3>
|
||||
<Table variant="bright">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{!isUtc && <TableHeaderCell>{schedule.timezone}</TableHeaderCell>}
|
||||
<TableHeaderCell>UTC</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{schedule.active ? (
|
||||
schedule.nextRuns.length ? (
|
||||
schedule.nextRuns.map((run, index) => (
|
||||
<TableRow key={index}>
|
||||
{!isUtc && (
|
||||
<TableCell>
|
||||
<DateTime date={run} timeZone={schedule.timezone} />
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<DateTime date={run} timeZone="UTC" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableBlankRow colSpan={isUtc ? 1 : 2}>
|
||||
<PlaceholderText title="You found a bug" />
|
||||
</TableBlankRow>
|
||||
)
|
||||
) : (
|
||||
<TableBlankRow colSpan={isUtc ? 1 : 2}>
|
||||
<PlaceholderText title="Schedule disabled" />
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{!isImperative && (
|
||||
<div className="p-3">
|
||||
<InfoPanel
|
||||
title="Editing declarative schedules"
|
||||
icon={BookOpenIcon}
|
||||
iconClassName="text-indigo-500"
|
||||
variant="info"
|
||||
accessory={
|
||||
<LinkButton
|
||||
to="https://trigger.dev/docs/v3/tasks-scheduled"
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
Schedules docs
|
||||
</LinkButton>
|
||||
}
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
You can only edit a declarative schedule by updating your schedules.task and then
|
||||
running the CLI dev and deploy commands.
|
||||
</InfoPanel>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isImperative && (
|
||||
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-2 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const ToggleForm = activeToggleFetcher?.Form ?? Form;
|
||||
const isSubmitting = activeToggleFetcher?.state === "submitting";
|
||||
return (
|
||||
<ToggleForm method="post" action={actionPath}>
|
||||
{activeToggleFetcher ? <input type="hidden" name="_format" value="json" /> : null}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary/small"
|
||||
LeadingIcon={schedule.active ? BoltSlashIcon : BoltIcon}
|
||||
leadingIconClassName={schedule.active ? "text-dimmed" : "text-success"}
|
||||
name="action"
|
||||
value={schedule.active ? "disable" : "enable"}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{schedule.active ? "Disable" : "Enable"}
|
||||
</Button>
|
||||
</ToggleForm>
|
||||
);
|
||||
})()}
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger/small"
|
||||
LeadingIcon={TrashIcon}
|
||||
name="action"
|
||||
value="delete"
|
||||
>
|
||||
Delete…
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>Delete schedule</DialogHeader>
|
||||
<DialogDescription className="mt-3">
|
||||
Are you sure you want to delete this schedule? This can't be reversed.
|
||||
</DialogDescription>
|
||||
<DialogFooter className="sm:justify-end">
|
||||
{(() => {
|
||||
const DeleteForm = deleteFetcher?.Form ?? Form;
|
||||
const isSubmitting = deleteFetcher?.state === "submitting";
|
||||
return (
|
||||
<DeleteForm method="post" action={actionPath}>
|
||||
{deleteFetcher ? <input type="hidden" name="_format" value="json" /> : null}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger/medium"
|
||||
LeadingIcon={TrashIcon}
|
||||
name="action"
|
||||
value="delete"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DeleteForm>
|
||||
);
|
||||
})()}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{onEdit ? (
|
||||
<Button variant="secondary/small" LeadingIcon={PencilSquareIcon} onClick={onEdit}>
|
||||
Edit schedule…
|
||||
</Button>
|
||||
) : (
|
||||
<LinkButton
|
||||
variant="secondary/small"
|
||||
to={`${v3EditSchedulePath(organization, project, environment, schedule)}${
|
||||
location.search
|
||||
}`}
|
||||
LeadingIcon={PencilSquareIcon}
|
||||
>
|
||||
Edit schedule…
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceholderText({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">{title}</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { PurchaseSchedulesModal, type SchedulePricing } from "./PurchaseSchedulesModal";
|
||||
|
||||
type Props = {
|
||||
actionPath: string;
|
||||
canPurchaseSchedules: boolean;
|
||||
schedulePricing: SchedulePricing | null;
|
||||
extraSchedules: number;
|
||||
limits: { used: number; limit: number };
|
||||
maxScheduleQuota: number;
|
||||
planScheduleLimit: number;
|
||||
canUpgrade: boolean;
|
||||
organization: MatchedOrganization;
|
||||
variant?: "dialog" | "banner";
|
||||
};
|
||||
|
||||
export function ScheduleLimitActions({
|
||||
actionPath,
|
||||
canPurchaseSchedules,
|
||||
schedulePricing,
|
||||
extraSchedules,
|
||||
limits,
|
||||
maxScheduleQuota,
|
||||
planScheduleLimit,
|
||||
canUpgrade,
|
||||
organization,
|
||||
variant = "banner",
|
||||
}: Props) {
|
||||
const showSelfServe = useShowSelfServe();
|
||||
|
||||
if (!showSelfServe) {
|
||||
return (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Request more</Button>}
|
||||
defaultValue="enterprise"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (canPurchaseSchedules && schedulePricing) {
|
||||
return (
|
||||
<PurchaseSchedulesModal
|
||||
actionPath={actionPath}
|
||||
schedulePricing={schedulePricing}
|
||||
extraSchedules={extraSchedules}
|
||||
usedSchedules={limits.used}
|
||||
maxQuota={maxScheduleQuota}
|
||||
planScheduleLimit={planScheduleLimit}
|
||||
triggerButton={
|
||||
variant === "dialog" ? <Button variant="primary/small">Purchase more…</Button> : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (canUpgrade) {
|
||||
return variant === "dialog" ? (
|
||||
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
) : (
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization)}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Feedback button={<Button variant="primary/small">Request more</Button>} defaultValue="help" />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { v3SchedulesAddOnPath } from "~/utils/pathBuilder";
|
||||
import { ScheduleLimitActions } from "./ScheduleLimitActions";
|
||||
import { type SchedulePricing } from "./PurchaseSchedulesModal";
|
||||
|
||||
type Props = {
|
||||
limits: { used: number; limit: number };
|
||||
/** True when the user has used all available schedules and cannot exceed the plan limit. */
|
||||
requiresUpgrade: boolean;
|
||||
/** True when the plan would let them upgrade (vs being already on the highest plan). */
|
||||
canUpgrade: boolean;
|
||||
canPurchaseSchedules: boolean;
|
||||
extraSchedules: number;
|
||||
maxScheduleQuota: number;
|
||||
planScheduleLimit: number;
|
||||
schedulePricing: SchedulePricing | null;
|
||||
};
|
||||
|
||||
export function SchedulesUsageBar({
|
||||
limits,
|
||||
requiresUpgrade,
|
||||
canUpgrade,
|
||||
canPurchaseSchedules,
|
||||
extraSchedules,
|
||||
maxScheduleQuota,
|
||||
planScheduleLimit,
|
||||
schedulePricing,
|
||||
}: Props) {
|
||||
const organization = useOrganization();
|
||||
const actionPath = v3SchedulesAddOnPath(organization);
|
||||
const ratio = limits.limit > 0 ? Math.min(limits.used / limits.limit, 1) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="size-6">
|
||||
<svg className="h-full w-full -rotate-90 overflow-visible">
|
||||
<circle
|
||||
className="fill-none stroke-grid-bright"
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
/>
|
||||
<circle
|
||||
className={`fill-none ${requiresUpgrade ? "stroke-error" : "stroke-success"}`}
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
strokeDasharray={`${ratio * 62.8} 62.8`}
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
content={`${Math.round(ratio * 100)}%`}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
{requiresUpgrade ? (
|
||||
<Header3 className="text-error">
|
||||
You've used all {limits.limit} of your available schedules. Upgrade your plan to
|
||||
enable more.
|
||||
</Header3>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Header3>
|
||||
You've used {limits.used}/{limits.limit} of your schedules
|
||||
</Header3>
|
||||
<InfoIconTooltip content="Schedules created in Dev don't count towards your limit." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScheduleLimitActions
|
||||
actionPath={actionPath}
|
||||
canPurchaseSchedules={canPurchaseSchedules}
|
||||
schedulePricing={schedulePricing}
|
||||
extraSchedules={extraSchedules}
|
||||
limits={limits}
|
||||
maxScheduleQuota={maxScheduleQuota}
|
||||
planScheduleLimit={planScheduleLimit}
|
||||
canUpgrade={canUpgrade}
|
||||
organization={organization}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user