Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
236 changes: 236 additions & 0 deletions packages/dashboard/src/components/create-run-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { Button } from "@/components/ui/button";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { createWorkflowRunServerFn } from "@/lib/api";
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";

interface CreateRunFormProps {
onCancel?: () => void;
onSuccess?: () => void;
}

function normalizeOptionalField(value: string): string | null {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}

function toIsoDateTime(value: string, fieldName: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new TypeError(`${fieldName} must be a valid date and time`);
}

return parsed.toISOString();
}

function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}

return "Unable to create workflow run";
}

export function CreateRunForm({ onCancel, onSuccess }: CreateRunFormProps) {
const navigate = useNavigate();

const [workflowName, setWorkflowName] = useState("");
const [version, setVersion] = useState("");
const [input, setInput] = useState("");
const [availableAt, setAvailableAt] = useState("");
const [deadlineAt, setDeadlineAt] = useState("");
const [inputError, setInputError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);

async function submitForm() {
if (!workflowName.trim()) {
setSubmitError("Workflow name is required");
return;
}

const normalizedAvailableAt = normalizeOptionalField(availableAt);
const normalizedDeadlineAt = normalizeOptionalField(deadlineAt);
let availableAtIso: string | null = null;
let deadlineAtIso: string | null = null;

try {
if (normalizedAvailableAt) {
availableAtIso = toIsoDateTime(normalizedAvailableAt, "Schedule for");
}
if (normalizedDeadlineAt) {
deadlineAtIso = toIsoDateTime(normalizedDeadlineAt, "Deadline");
}
} catch (error) {
setSubmitError(getErrorMessage(error));
return;
}

const normalizedInput = normalizeOptionalField(input);
if (normalizedInput) {
try {
JSON.parse(normalizedInput);
} catch {
setInputError("Input must be valid JSON");
Comment on lines +81 to +82
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON.parse call at line 80 is wrapped in a try-catch, but the error is caught and a generic message "Input must be valid JSON" is set. Consider providing more specific error feedback that includes the parsing error details (e.g., the specific JSON syntax error) to help users fix their input more easily. For example: "Input must be valid JSON: unexpected token at position X".

Suggested change
} catch {
setInputError("Input must be valid JSON");
} catch (error) {
const baseMessage = "Input must be valid JSON";
if (error instanceof SyntaxError && error.message) {
setInputError(`${baseMessage}: ${error.message}`);
} else if (error instanceof Error && error.message) {
setInputError(`${baseMessage}: ${error.message}`);
} else {
setInputError(baseMessage);
}

Copilot uses AI. Check for mistakes.
return;
}
}
Comment on lines +78 to +85
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate JSON validation logic exists in both the form and the API handler. The JSON parsing is performed here in the form and again in the API handler at line 122-126 of packages/dashboard/src/lib/api.ts. Consider removing this client-side validation and relying on the server-side validation, or ensure they remain synchronized if both are necessary for UX reasons.

Suggested change
if (normalizedInput) {
try {
JSON.parse(normalizedInput);
} catch {
setInputError("Input must be valid JSON");
return;
}
}

Copilot uses AI. Check for mistakes.

setInputError(null);
setSubmitError(null);
setIsSubmitting(true);

try {
const run = await createWorkflowRunServerFn({
data: {
workflowName: workflowName.trim(),
version: normalizeOptionalField(version),
input: normalizedInput,
availableAt: availableAtIso,
deadlineAt: deadlineAtIso,
},
});

onSuccess?.();
await navigate({
to: "/runs/$runId",
params: { runId: run.id },
});
Comment on lines +102 to +106
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After successfully creating a workflow run, the form navigates to the run detail page, but the homepage data won't be immediately refreshed. While the usePolling hook will eventually update the list (every 2 seconds), consider calling router.invalidate() before navigation to ensure the homepage shows the new run when users navigate back. This would provide a better user experience.

Copilot uses AI. Check for mistakes.
} catch (error) {
setSubmitError(getErrorMessage(error));
} finally {
setIsSubmitting(false);
}
}
Comment on lines +42 to +112
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the dialog closes via onSuccess or onCancel, the form state (workflowName, version, input, etc.) is not reset. This means if a user opens the dialog again after creating a run, the form will still contain the previous values. Consider resetting the form state when the dialog closes or when it opens to provide a clean form experience.

Copilot uses AI. Check for mistakes.

function handleSubmit(event: React.SyntheticEvent<HTMLFormElement>) {
event.preventDefault();
void submitForm();
}

return (
<form className="grid gap-5" onSubmit={handleSubmit}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="create-run-workflow-name">
Workflow Name *
</FieldLabel>
<Input
id="create-run-workflow-name"
name="workflowName"
value={workflowName}
onChange={(event) => {
setWorkflowName(event.currentTarget.value);
}}
placeholder="e.g. hello-world"
required
disabled={isSubmitting}
/>
</Field>
</FieldGroup>

<div className="space-y-4">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
Optional
</p>
<FieldGroup>
<Field data-invalid={!!inputError}>
<FieldLabel htmlFor="create-run-input">Input (JSON)</FieldLabel>
<Textarea
id="create-run-input"
name="input"
value={input}
onChange={(event) => {
setInput(event.currentTarget.value);
if (inputError) {
setInputError(null);
}
}}
rows={6}
placeholder='{"key":"value"}'
disabled={isSubmitting}
/>
<FieldDescription>
JSON payload passed to the workflow function.
</FieldDescription>
<FieldError>{inputError}</FieldError>
</Field>

<div className="grid gap-5 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="create-run-available-at">
Schedule For
</FieldLabel>
<Input
id="create-run-available-at"
name="availableAt"
type="datetime-local"
value={availableAt}
onChange={(event) => {
setAvailableAt(event.currentTarget.value);
}}
disabled={isSubmitting}
/>
<FieldDescription>
Leave empty to start immediately.
</FieldDescription>
</Field>

<Field>
<FieldLabel htmlFor="create-run-deadline-at">Deadline</FieldLabel>
<Input
id="create-run-deadline-at"
name="deadlineAt"
type="datetime-local"
value={deadlineAt}
onChange={(event) => {
setDeadlineAt(event.currentTarget.value);
}}
disabled={isSubmitting}
/>
<FieldDescription>Leave empty for no deadline.</FieldDescription>
</Field>
Comment on lines +187 to +200
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The datetime-local input type is used for availableAt and deadlineAt fields, but there's no validation to ensure that deadlineAt is after availableAt if both are provided. Consider adding this logical validation to prevent users from setting a deadline before the scheduled start time, which would be an invalid configuration.

Copilot uses AI. Check for mistakes.
</div>

<Field className="sm:max-w-sm">
<FieldLabel htmlFor="create-run-version">Version</FieldLabel>
<Input
id="create-run-version"
name="version"
value={version}
onChange={(event) => {
setVersion(event.currentTarget.value);
}}
placeholder="e.g. v2"
disabled={isSubmitting}
/>
</Field>
</FieldGroup>
</div>

<FieldError>{submitError}</FieldError>

<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
type="button"
onClick={onCancel}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Run"}
</Button>
</div>
</form>
);
}
39 changes: 26 additions & 13 deletions packages/dashboard/src/components/run-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,27 @@ import type { WorkflowRun } from "openworkflow/internal";
export interface RunListProps {
runs: WorkflowRun[];
title?: string;
showHeader?: boolean;
showCount?: boolean;
}

export function RunList({ runs, title = "Workflow Runs" }: RunListProps) {
export function RunList({
runs,
title = "Workflow Runs",
showHeader = true,
showCount = true,
}: RunListProps) {
if (runs.length === 0) {
return (
<div className="space-y-4">
<div>
<h2 className="text-2xl font-semibold">{title}</h2>
<p className="text-muted-foreground mt-1 text-sm">
No workflow runs found
</p>
</div>
{showHeader && (
<div>
<h2 className="text-2xl font-semibold">{title}</h2>
<p className="text-muted-foreground mt-1 text-sm">
No workflow runs found
</p>
</div>
)}
<Card className="bg-card border-border p-8 text-center">
<p className="text-muted-foreground">
No workflow runs have been created yet.
Expand All @@ -33,12 +42,16 @@ export function RunList({ runs, title = "Workflow Runs" }: RunListProps) {

return (
<div className="space-y-4">
<div>
<h2 className="text-2xl font-semibold">{title}</h2>
<p className="text-muted-foreground mt-1 text-sm">
{runs.length} workflow run{runs.length === 1 ? "" : "s"}
</p>
</div>
{showHeader && (
<div>
<h2 className="text-2xl font-semibold">{title}</h2>
{showCount && (
<p className="text-muted-foreground mt-1 text-sm">
{runs.length} workflow run{runs.length === 1 ? "" : "s"}
</p>
)}
</div>
)}

<Card className="bg-card border-border overflow-hidden py-0">
<div className="divide-border divide-y">
Expand Down
Loading