mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add workflow duration sliders
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { FieldLabel } from "./workflowFormPrimitives";
|
||||
import {
|
||||
DEFAULT_DURATION_SECONDS,
|
||||
DURATION_SLIDER_STOPS,
|
||||
durationSliderIndex,
|
||||
formatDurationSeconds,
|
||||
parseDurationSeconds,
|
||||
} from "./workflowDuration";
|
||||
|
||||
export function WorkflowDurationField({
|
||||
disabled,
|
||||
fallbackSeconds = DEFAULT_DURATION_SECONDS,
|
||||
id,
|
||||
label = "Duration",
|
||||
onChange,
|
||||
placeholder = "1s",
|
||||
value,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
fallbackSeconds?: number;
|
||||
id: string;
|
||||
label?: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
}) {
|
||||
const parsedSeconds = parseDurationSeconds(value);
|
||||
const sliderIndex = durationSliderIndex(
|
||||
Math.max(DURATION_SLIDER_STOPS[0], parsedSeconds ?? fallbackSeconds),
|
||||
);
|
||||
const progress = (sliderIndex / (DURATION_SLIDER_STOPS.length - 1)) * 100;
|
||||
const sliderSeconds = DURATION_SLIDER_STOPS[sliderIndex];
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex h-9 min-w-0 flex-1 items-center">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 h-1.5 overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
aria-label={`${label} slider`}
|
||||
aria-valuetext={formatDurationSeconds(sliderSeconds)}
|
||||
className="absolute inset-x-0 h-9 w-full cursor-pointer appearance-none bg-transparent disabled:cursor-not-allowed disabled:opacity-50 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-moz-range-track]:bg-transparent [&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:bg-transparent [&::-webkit-slider-thumb]:mt-[-0.3125rem] [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm focus-visible:outline-hidden focus-visible:[&::-moz-range-thumb]:ring-2 focus-visible:[&::-moz-range-thumb]:ring-ring focus-visible:[&::-moz-range-thumb]:ring-offset-2 focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-ring focus-visible:[&::-webkit-slider-thumb]:ring-offset-2"
|
||||
disabled={disabled}
|
||||
max={DURATION_SLIDER_STOPS.length - 1}
|
||||
min={0}
|
||||
onChange={(event) => {
|
||||
const seconds = DURATION_SLIDER_STOPS[Number(event.target.value)];
|
||||
onChange(formatDurationSeconds(seconds));
|
||||
}}
|
||||
type="range"
|
||||
value={sliderIndex}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className="w-28 shrink-0 text-center tabular-nums"
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
onBlur={() => {
|
||||
if (parsedSeconds !== null) {
|
||||
onChange(
|
||||
formatDurationSeconds(
|
||||
Math.max(DURATION_SLIDER_STOPS[0], parsedSeconds),
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -443,6 +443,9 @@ export function WorkflowFormBuilder({
|
||||
id: nextStepId(formState.steps),
|
||||
action,
|
||||
};
|
||||
if (action === "delay") {
|
||||
newStep.duration = "1s";
|
||||
}
|
||||
if (action === "call_webhook") {
|
||||
newStep.method = "POST";
|
||||
}
|
||||
@@ -711,6 +714,9 @@ export function WorkflowFormBuilder({
|
||||
labels={ACTION_LABELS}
|
||||
onChange={(action) => {
|
||||
const next = { ...selectedStep, action };
|
||||
if (action === "delay" && !next.duration) {
|
||||
next.duration = "1s";
|
||||
}
|
||||
if (action === "call_webhook" && !next.method) {
|
||||
next.method = "POST";
|
||||
}
|
||||
|
||||
@@ -6,12 +6,16 @@ import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { ChannelCombobox } from "./ChannelCombobox";
|
||||
import { WorkflowDurationField } from "./WorkflowDurationField";
|
||||
import { WorkflowEmojiField } from "./WorkflowEmojiField";
|
||||
import { WorkflowTemplateTextarea } from "./WorkflowTemplateTextarea";
|
||||
import { FieldLabel, FormSelect } from "./workflowFormPrimitives";
|
||||
import { WorkflowWebhookHeadersEditor } from "./WorkflowWebhookHeadersEditor";
|
||||
import type { StepFormState, TriggerType } from "./workflowFormTypes";
|
||||
|
||||
const DEFAULT_STEP_TIMEOUT_SECONDS = 5 * 60;
|
||||
const DEFAULT_APPROVAL_TIMEOUT_SECONDS = 24 * 60 * 60;
|
||||
|
||||
function BackendSupportHint({ action }: { action: StepFormState["action"] }) {
|
||||
switch (action) {
|
||||
case "send_dm":
|
||||
@@ -69,19 +73,12 @@ function StepConfigFields({
|
||||
switch (step.action) {
|
||||
case "delay":
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-duration`}>Duration</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-duration`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, duration: event.target.value })
|
||||
}
|
||||
placeholder="e.g. 5s, 1m, 1h"
|
||||
value={step.duration ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<WorkflowDurationField
|
||||
disabled={disabled}
|
||||
id={`${prefix}-duration`}
|
||||
onChange={(duration) => onUpdate({ ...step, duration })}
|
||||
value={step.duration ?? ""}
|
||||
/>
|
||||
);
|
||||
case "send_message":
|
||||
return (
|
||||
@@ -253,21 +250,15 @@ function StepConfigFields({
|
||||
value={step.message ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-timeout`}>
|
||||
Timeout (optional)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
disabled={disabled}
|
||||
id={`${prefix}-timeout`}
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, timeout: event.target.value })
|
||||
}
|
||||
placeholder="e.g. 24h"
|
||||
value={step.timeout ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<WorkflowDurationField
|
||||
disabled={disabled}
|
||||
fallbackSeconds={DEFAULT_APPROVAL_TIMEOUT_SECONDS}
|
||||
id={`${prefix}-timeout`}
|
||||
label="Timeout (optional)"
|
||||
onChange={(timeout) => onUpdate({ ...step, timeout })}
|
||||
placeholder="24h"
|
||||
value={step.timeout ?? ""}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "add_reaction":
|
||||
@@ -380,19 +371,14 @@ export function WorkflowStepCard({
|
||||
|
||||
<section className="space-y-4 border-t border-border/50 py-5">
|
||||
<SectionHeading title="Step timeout" />
|
||||
<div className="space-y-1.5">
|
||||
<FieldLabel htmlFor={`${prefix}-timeout-secs`}>
|
||||
Timeout (seconds)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoCapitalize="off"
|
||||
<div className="space-y-2">
|
||||
<WorkflowDurationField
|
||||
disabled={disabled}
|
||||
fallbackSeconds={DEFAULT_STEP_TIMEOUT_SECONDS}
|
||||
id={`${prefix}-timeout-secs`}
|
||||
inputMode="numeric"
|
||||
onChange={(event) =>
|
||||
onUpdate({ ...step, timeoutSecs: event.target.value })
|
||||
}
|
||||
placeholder="e.g. 300"
|
||||
label="Timeout (optional)"
|
||||
onChange={(timeoutSecs) => onUpdate({ ...step, timeoutSecs })}
|
||||
placeholder="5m"
|
||||
value={step.timeoutSecs ?? ""}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DURATION_SLIDER_STOPS,
|
||||
durationSliderIndex,
|
||||
formatDurationSeconds,
|
||||
formatDurationSecondsVerbose,
|
||||
parseDurationSeconds,
|
||||
} from "./workflowDuration.ts";
|
||||
|
||||
test("parseDurationSeconds accepts compact and combined whole-second durations", () => {
|
||||
assert.equal(parseDurationSeconds("5s"), 5);
|
||||
assert.equal(parseDurationSeconds("90m"), 5_400);
|
||||
assert.equal(parseDurationSeconds("1h 2s"), 3_602);
|
||||
assert.equal(parseDurationSeconds("1H2M3S"), 3_723);
|
||||
assert.equal(parseDurationSeconds("2d"), 172_800);
|
||||
assert.equal(parseDurationSeconds("2w 3d 4h 5m 6s"), 1_483_506);
|
||||
assert.equal(parseDurationSeconds("42"), 42);
|
||||
assert.equal(parseDurationSeconds("0s"), 0);
|
||||
});
|
||||
|
||||
test("parseDurationSeconds rejects empty, malformed, and fractional values", () => {
|
||||
assert.equal(parseDurationSeconds(""), null);
|
||||
assert.equal(parseDurationSeconds("1.5m"), null);
|
||||
assert.equal(parseDurationSeconds("1m later"), null);
|
||||
});
|
||||
|
||||
test("formatDurationSeconds produces compact labels with significant units", () => {
|
||||
assert.equal(formatDurationSeconds(0), "0s");
|
||||
assert.equal(formatDurationSeconds(5), "5s");
|
||||
assert.equal(formatDurationSeconds(62), "1m 2s");
|
||||
assert.equal(formatDurationSeconds(3_602), "1h 2s");
|
||||
assert.equal(formatDurationSeconds(7_323), "2h 2m 3s");
|
||||
assert.equal(formatDurationSeconds(172_800), "2d");
|
||||
assert.equal(formatDurationSeconds(1_483_506), "2w 3d 4h 5m 6s");
|
||||
});
|
||||
|
||||
test("formatDurationSecondsVerbose spells out units with correct plurals", () => {
|
||||
assert.equal(formatDurationSecondsVerbose(0), "0 seconds");
|
||||
assert.equal(formatDurationSecondsVerbose(300), "5 minutes");
|
||||
assert.equal(formatDurationSecondsVerbose(604_800), "1 week");
|
||||
assert.equal(
|
||||
formatDurationSecondsVerbose(1_483_506),
|
||||
"2 weeks 3 days 4 hours 5 minutes 6 seconds",
|
||||
);
|
||||
});
|
||||
|
||||
test("duration slider starts at one second, keeps fine short-delay stops, and reaches three hours", () => {
|
||||
assert.deepEqual(DURATION_SLIDER_STOPS.slice(0, 3), [1, 2, 3]);
|
||||
assert.equal(DURATION_SLIDER_STOPS.at(-1), 10_800);
|
||||
assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(62)], 62);
|
||||
assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(300)], 300);
|
||||
assert.equal(DURATION_SLIDER_STOPS[durationSliderIndex(3_602)], 3_600);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
const DURATION_PARTS_PATTERN =
|
||||
/^\s*(?:(\d+)\s*w)?\s*(?:(\d+)\s*d)?\s*(?:(\d+)\s*h)?\s*(?:(\d+)\s*m)?\s*(?:(\d+)\s*s)?\s*$/i;
|
||||
|
||||
const SECONDS_PER_MINUTE = 60;
|
||||
const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE;
|
||||
const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
|
||||
const SECONDS_PER_WEEK = 7 * SECONDS_PER_DAY;
|
||||
|
||||
/** Parse compact durations such as `5s`, `1h 2s`, `2d`, or `3w`. */
|
||||
export function parseDurationSeconds(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const seconds = Number(trimmed);
|
||||
return Number.isSafeInteger(seconds) ? seconds : null;
|
||||
}
|
||||
|
||||
const match = DURATION_PARTS_PATTERN.exec(trimmed);
|
||||
if (!match || match.slice(1).every((part) => part === undefined)) return null;
|
||||
|
||||
const weeks = Number(match[1] ?? 0);
|
||||
const days = Number(match[2] ?? 0);
|
||||
const hours = Number(match[3] ?? 0);
|
||||
const minutes = Number(match[4] ?? 0);
|
||||
const seconds = Number(match[5] ?? 0);
|
||||
const total =
|
||||
weeks * SECONDS_PER_WEEK +
|
||||
days * SECONDS_PER_DAY +
|
||||
hours * SECONDS_PER_HOUR +
|
||||
minutes * SECONDS_PER_MINUTE +
|
||||
seconds;
|
||||
|
||||
return Number.isSafeInteger(total) ? total : null;
|
||||
}
|
||||
|
||||
/** Format whole seconds as a compact duration, omitting empty units. */
|
||||
export function formatDurationSeconds(totalSeconds: number): string {
|
||||
if (!Number.isSafeInteger(totalSeconds) || totalSeconds < 0) return "";
|
||||
if (totalSeconds === 0) return "0s";
|
||||
|
||||
const weeks = Math.floor(totalSeconds / SECONDS_PER_WEEK);
|
||||
const days = Math.floor((totalSeconds % SECONDS_PER_WEEK) / SECONDS_PER_DAY);
|
||||
const hours = Math.floor((totalSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
|
||||
const minutes = Math.floor(
|
||||
(totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
|
||||
);
|
||||
const seconds = totalSeconds % SECONDS_PER_MINUTE;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (weeks > 0) parts.push(`${weeks}w`);
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (seconds > 0) parts.push(`${seconds}s`);
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function verboseUnit(value: number, unit: string): string {
|
||||
return `${value} ${unit}${value === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
/** Format whole seconds with fully spelled-out units for summary UI. */
|
||||
export function formatDurationSecondsVerbose(totalSeconds: number): string {
|
||||
if (!Number.isSafeInteger(totalSeconds) || totalSeconds < 0) return "";
|
||||
if (totalSeconds === 0) return "0 seconds";
|
||||
|
||||
const weeks = Math.floor(totalSeconds / SECONDS_PER_WEEK);
|
||||
const days = Math.floor((totalSeconds % SECONDS_PER_WEEK) / SECONDS_PER_DAY);
|
||||
const hours = Math.floor((totalSeconds % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
|
||||
const minutes = Math.floor(
|
||||
(totalSeconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE,
|
||||
);
|
||||
const seconds = totalSeconds % SECONDS_PER_MINUTE;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (weeks > 0) parts.push(verboseUnit(weeks, "week"));
|
||||
if (days > 0) parts.push(verboseUnit(days, "day"));
|
||||
if (hours > 0) parts.push(verboseUnit(hours, "hour"));
|
||||
if (minutes > 0) parts.push(verboseUnit(minutes, "minute"));
|
||||
if (seconds > 0) parts.push(verboseUnit(seconds, "second"));
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function steppedRange(start: number, end: number, step: number): number[] {
|
||||
const values: number[] = [];
|
||||
for (let value = start; value <= end; value += step) values.push(value);
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slider stops favor the short delays people use most, then relax precision as
|
||||
* the duration grows. The typed field still accepts exact values between stops.
|
||||
*/
|
||||
export const DURATION_SLIDER_STOPS = [
|
||||
...steppedRange(1, 120, 1),
|
||||
...steppedRange(125, 600, 5),
|
||||
...steppedRange(615, 1_800, 15),
|
||||
...steppedRange(1_860, 7_200, 60),
|
||||
...steppedRange(7_500, 10_800, 300),
|
||||
];
|
||||
|
||||
export const DEFAULT_DURATION_SECONDS = 1;
|
||||
|
||||
export function durationSliderIndex(totalSeconds: number): number {
|
||||
if (totalSeconds <= DURATION_SLIDER_STOPS[0]) return 0;
|
||||
|
||||
const lastIndex = DURATION_SLIDER_STOPS.length - 1;
|
||||
if (totalSeconds >= DURATION_SLIDER_STOPS[lastIndex]) return lastIndex;
|
||||
|
||||
let low = 0;
|
||||
let high = lastIndex;
|
||||
while (low <= high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
const value = DURATION_SLIDER_STOPS[middle];
|
||||
if (value === totalSeconds) return middle;
|
||||
if (value < totalSeconds) low = middle + 1;
|
||||
else high = middle - 1;
|
||||
}
|
||||
|
||||
return totalSeconds - DURATION_SLIDER_STOPS[high] <=
|
||||
DURATION_SLIDER_STOPS[low] - totalSeconds
|
||||
? high
|
||||
: low;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { yamlToFormState } from "./workflowFormTypes.ts";
|
||||
import { formStateToYaml, yamlToFormState } from "./workflowFormTypes.ts";
|
||||
|
||||
test("keeps workflows with step conditions in the YAML editor", () => {
|
||||
const result = yamlToFormState(`name: conditional_step
|
||||
@@ -19,3 +19,66 @@ steps:
|
||||
error: "Step conditions are only available in the YAML editor",
|
||||
});
|
||||
});
|
||||
|
||||
test("presents saved delay durations nicely and serializes them for the backend", () => {
|
||||
const parsed = yamlToFormState(`name: delayed_workflow
|
||||
trigger:
|
||||
on: message_posted
|
||||
steps:
|
||||
- id: wait
|
||||
action: delay
|
||||
duration: 3602s
|
||||
`);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
if (!parsed.ok) return;
|
||||
|
||||
assert.equal(parsed.state.steps[0].duration, "1h 2s");
|
||||
assert.match(formStateToYaml(parsed.state), /duration: 3602s/);
|
||||
});
|
||||
|
||||
test("presents UI timeouts nicely and serializes numeric seconds", () => {
|
||||
const parsed = yamlToFormState(`name: timeout_workflow
|
||||
trigger:
|
||||
on: message_posted
|
||||
steps:
|
||||
- id: notify
|
||||
action: send_message
|
||||
timeout_secs: 3602
|
||||
text: hello
|
||||
- id: approve
|
||||
action: request_approval
|
||||
from: manager
|
||||
message: Ship it?
|
||||
timeout: 2d
|
||||
`);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
if (!parsed.ok) return;
|
||||
|
||||
assert.equal(parsed.state.steps[0].timeoutSecs, "1h 2s");
|
||||
assert.equal(parsed.state.steps[1].timeout, "2d");
|
||||
|
||||
const serialized = formStateToYaml(parsed.state);
|
||||
assert.match(serialized, /timeout_secs: 3602/);
|
||||
assert.match(serialized, /timeout: 172800s/);
|
||||
});
|
||||
|
||||
test("clamps explicit zero delays and timeouts to the one-second UI minimum", () => {
|
||||
const parsed = yamlToFormState(`name: minimum_workflow
|
||||
trigger:
|
||||
on: message_posted
|
||||
steps:
|
||||
- id: wait
|
||||
action: delay
|
||||
timeout_secs: 0
|
||||
duration: 0s
|
||||
`);
|
||||
|
||||
assert.equal(parsed.ok, true);
|
||||
if (!parsed.ok) return;
|
||||
|
||||
const serialized = formStateToYaml(parsed.state);
|
||||
assert.match(serialized, /timeout_secs: 1/);
|
||||
assert.match(serialized, /duration: 1s/);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { stringify as yamlStringify, parse as yamlParse } from "yaml";
|
||||
import {
|
||||
formatDurationSeconds,
|
||||
parseDurationSeconds,
|
||||
} from "./workflowDuration";
|
||||
|
||||
export const TRIGGER_TYPES = [
|
||||
"message_posted",
|
||||
@@ -128,10 +132,28 @@ function headersToRecord(
|
||||
|
||||
function parseTimeoutSecs(timeoutSecs: string | undefined): number | undefined {
|
||||
if (!timeoutSecs) return undefined;
|
||||
const trimmed = timeoutSecs.trim();
|
||||
if (!/^\d+$/.test(trimmed)) return undefined;
|
||||
const parsed = Number(trimmed);
|
||||
return parsed > 0 ? parsed : undefined;
|
||||
const parsed = parseDurationSeconds(timeoutSecs);
|
||||
return parsed === null ? undefined : Math.max(1, parsed);
|
||||
}
|
||||
|
||||
function presentDuration(duration: unknown): string | undefined {
|
||||
if (typeof duration === "number") {
|
||||
return Number.isSafeInteger(duration) && duration >= 0
|
||||
? formatDurationSeconds(duration)
|
||||
: undefined;
|
||||
}
|
||||
if (typeof duration !== "string") return undefined;
|
||||
const durationSeconds = parseDurationSeconds(duration.trim());
|
||||
return durationSeconds === null
|
||||
? duration
|
||||
: formatDurationSeconds(durationSeconds);
|
||||
}
|
||||
|
||||
function serializeDuration(duration: string): string {
|
||||
const durationSeconds = parseDurationSeconds(duration);
|
||||
return durationSeconds === null
|
||||
? duration.trim()
|
||||
: `${Math.max(1, durationSeconds)}s`;
|
||||
}
|
||||
|
||||
function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
|
||||
@@ -142,7 +164,9 @@ function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
|
||||
|
||||
switch (step.action) {
|
||||
case "delay":
|
||||
if (step.duration) fields.duration = step.duration;
|
||||
if (step.duration) {
|
||||
fields.duration = serializeDuration(step.duration);
|
||||
}
|
||||
break;
|
||||
case "send_message":
|
||||
if (step.text) fields.text = step.text;
|
||||
@@ -164,7 +188,7 @@ function actionFieldsForStep(step: StepFormState): Record<string, unknown> {
|
||||
case "request_approval":
|
||||
if (step.from) fields.from = step.from;
|
||||
if (step.message) fields.message = step.message;
|
||||
if (step.timeout) fields.timeout = step.timeout;
|
||||
if (step.timeout) fields.timeout = serializeDuration(step.timeout);
|
||||
break;
|
||||
case "add_reaction":
|
||||
if (step.emoji) fields.emoji = step.emoji;
|
||||
@@ -281,9 +305,9 @@ export function yamlToFormState(
|
||||
action: (step.action as ActionType) ?? ACTION_TYPES[0],
|
||||
timeoutSecs:
|
||||
step.timeout_secs !== undefined
|
||||
? String(step.timeout_secs)
|
||||
? presentDuration(step.timeout_secs)
|
||||
: undefined,
|
||||
duration: step.duration as string | undefined,
|
||||
duration: presentDuration(step.duration),
|
||||
text: step.text as string | undefined,
|
||||
channel: step.channel as string | undefined,
|
||||
to: step.to as string | undefined,
|
||||
@@ -298,7 +322,7 @@ export function yamlToFormState(
|
||||
topic: step.topic as string | undefined,
|
||||
from: step.from as string | undefined,
|
||||
message: step.message as string | undefined,
|
||||
timeout: step.timeout as string | undefined,
|
||||
timeout: presentDuration(step.timeout),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -31,7 +31,15 @@ test("describes configured workflow steps on the canvas", () => {
|
||||
action: "delay",
|
||||
duration: "5m",
|
||||
}),
|
||||
"5m",
|
||||
"5 minutes",
|
||||
);
|
||||
assert.equal(
|
||||
workflowStepDescription({
|
||||
id: "long-delay",
|
||||
action: "delay",
|
||||
duration: "4w",
|
||||
}),
|
||||
"4 weeks",
|
||||
);
|
||||
assert.equal(
|
||||
workflowStepDescription({
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import {
|
||||
formatDurationSecondsVerbose,
|
||||
parseDurationSeconds,
|
||||
} from "./workflowDuration";
|
||||
import { ACTION_LABELS } from "./workflowFormTypes";
|
||||
import type { StepFormState } from "./workflowFormTypes";
|
||||
|
||||
@@ -29,8 +33,14 @@ function configuredStepDetail(
|
||||
channelLabel?: string,
|
||||
): string | null {
|
||||
switch (step.action) {
|
||||
case "delay":
|
||||
return step.duration?.trim() || null;
|
||||
case "delay": {
|
||||
const duration = step.duration?.trim();
|
||||
if (!duration) return null;
|
||||
const seconds = parseDurationSeconds(duration);
|
||||
return compact(
|
||||
seconds === null ? duration : formatDurationSecondsVerbose(seconds),
|
||||
);
|
||||
}
|
||||
case "send_message": {
|
||||
const text = quoted(step.text);
|
||||
const channel = channelLabel ? `#${channelLabel}` : null;
|
||||
|
||||
Reference in New Issue
Block a user