Skip to content
Open
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
82 changes: 78 additions & 4 deletions src/components/shared/Submitters/Oasis/OasisSubmitter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useBackend } from "@/providers/BackendProvider";
import { APP_ROUTES } from "@/routes/router";
import { updateRunNotes } from "@/services/pipelineRunService";
import type { PipelineRun } from "@/types/pipelineRun";
import { expandBulkArguments } from "@/utils/bulkSubmission";
import {
type ArgumentType,
type ComponentSpec,
Expand Down Expand Up @@ -107,6 +108,11 @@ const OasisSubmitter = ({
const navigate = useNavigate();

const runNotes = useRef<string>("");
const [bulkProgress, setBulkProgress] = useState<{
total: number;
completed: number;
failed: number;
} | null>(null);

const { mutate: saveNotes } = useMutation({
mutationFn: (runId: string) =>
Expand Down Expand Up @@ -198,18 +204,85 @@ const OasisSubmitter = ({
});
};

const handleSubmitWithArguments = (
const handleBulkSubmit = async (argSets: Record<string, ArgumentType>[]) => {
if (!componentSpec) {
handleError("No pipeline to submit");
return;
}

const total = argSets.length;
setBulkProgress({ total, completed: 0, failed: 0 });
setSubmitSuccess(null);

let completed = 0;
let failed = 0;

for (const args of argSets) {
try {
const response = await new Promise<PipelineRun>((resolve, reject) => {
submit({
componentSpec,
taskArguments: args,
onSuccess: resolve,
onError: reject,
});
});

if (runNotes.current.trim() !== "") {
saveNotes(response.id.toString());
}
Comment on lines +231 to +233
Copy link
Collaborator

Choose a reason for hiding this comment

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

this will need updating for tags now too


completed++;
} catch {
failed++;
}

setBulkProgress({ total, completed, failed });
}

setBulkProgress(null);
setSubmitSuccess(failed === 0);
setCooldownTime(3);

if (failed === 0) {
onSubmitComplete?.();
notify(`All ${total} runs submitted successfully`, "success");
} else {
notify(
`${completed} of ${total} runs submitted. ${failed} failed.`,
failed === total ? "error" : "warning",
);
}
};

const handleSubmitWithArguments = async (
args: Record<string, ArgumentType>,
notes: string,
bulkInputNames: Set<string>,
) => {
runNotes.current = notes;
setIsArgumentsDialogOpen(false);
handleSubmit(args);

if (bulkInputNames.size === 0) {
handleSubmit(args);
return;
}

try {
const argSets = expandBulkArguments(args, bulkInputNames);
await handleBulkSubmit(argSets);
} catch (error) {
notify(`Bulk submission failed: ${String(error)}`, "error");
}
};

const hasConfigurableInputs = (componentSpec?.inputs?.length ?? 0) > 0;

const getButtonText = () => {
if (bulkProgress) {
const current = bulkProgress.completed + bulkProgress.failed;
return `Submitting ${current + 1} of ${bulkProgress.total}...`;
}
if (cooldownTime > 0) {
return `Run submitted (${cooldownTime}s)`;
}
Expand All @@ -228,13 +301,14 @@ const OasisSubmitter = ({
isGraphImplementation(componentSpec.implementation) &&
Object.keys(componentSpec.implementation.graph.tasks).length > 0;

const isButtonDisabled = isSubmitting || !isSubmittable;
const isBulkSubmitting = bulkProgress !== null;
const isButtonDisabled = isSubmitting || isBulkSubmitting || !isSubmittable;

const isArgumentsButtonVisible =
hasConfigurableInputs && !isButtonDisabled && isComponentTreeValid;

const getButtonIcon = () => {
if (isSubmitting) {
if (isSubmitting || isBulkSubmitting) {
return <Loader2 className="animate-spin" />;
}
if (submitSuccess === false && cooldownTime > 0) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
createSecretArgument,
extractSecretName,
} from "@/components/shared/SecretsManagement/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
Expand All @@ -28,6 +29,7 @@ import {
} from "@/components/ui/dialog";
import { Icon } from "@/components/ui/icon";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import {
Popover,
Expand All @@ -36,6 +38,7 @@ import {
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Paragraph } from "@/components/ui/typography";
import useToastNotification from "@/hooks/useToastNotification";
Expand All @@ -46,6 +49,7 @@ import {
fetchPipelineRun,
} from "@/services/executionService";
import type { PipelineRun } from "@/types/pipelineRun";
import { getBulkRunCount, parseBulkValues } from "@/utils/bulkSubmission";
import {
type ArgumentType,
type ComponentSpec,
Expand All @@ -60,7 +64,11 @@ type TaskArguments = TaskSpecOutput["arguments"];
interface SubmitTaskArgumentsDialogProps {
open: boolean;
onCancel: () => void;
onConfirm: (args: Record<string, ArgumentType>, notes: string) => void;
onConfirm: (
args: Record<string, ArgumentType>,
notes: string,
bulkInputNames: Set<string>,
) => void;
componentSpec: ComponentSpec;
}

Expand All @@ -82,8 +90,15 @@ export const SubmitTaskArgumentsDialog = ({
new Map(),
);

const [bulkInputNames, setBulkInputNames] = useState<Set<string>>(new Set());

const inputs = componentSpec.inputs ?? [];

const bulkRunCount = getBulkRunCount(taskArguments, bulkInputNames);
const hasBulkMismatch = bulkRunCount === -1;
const isBulkMode = bulkInputNames.size > 0;
const effectiveRunCount = isBulkMode ? Math.max(bulkRunCount, 0) : 1;

const [isValidToSubmit, setIsValidToSubmit] = useState(
validateArguments(inputs, taskArguments),
);
Expand Down Expand Up @@ -112,19 +127,31 @@ export const SubmitTaskArgumentsDialog = ({
};

useEffect(() => {
setIsValidToSubmit(validateArguments(inputs, taskArguments));
}, [inputs, taskArguments]);

const handleRunNotesChange = (value: string) => {
setRunNotes(value);
const baseValid = validateArguments(inputs, taskArguments);
const bulkValid = !hasBulkMismatch && bulkRunCount > 0;
setIsValidToSubmit(baseValid && bulkValid);
}, [inputs, taskArguments, hasBulkMismatch, bulkRunCount]);

const handleBulkToggle = (name: string, enabled: boolean) => {
setBulkInputNames((prev) => {
const next = new Set(prev);
if (enabled) {
next.add(name);
} else {
next.delete(name);
}
return next;
});
};

const handleConfirm = () => onConfirm(taskArguments, runNotes);
const handleConfirm = () =>
onConfirm(taskArguments, runNotes, bulkInputNames);

const handleCancel = () => {
setTaskArguments(initialArgs);
setRunNotes("");
setHighlightedArgs(new Map());
setBulkInputNames(new Set());
onCancel();
};

Expand Down Expand Up @@ -160,18 +187,48 @@ export const SubmitTaskArgumentsDialog = ({
)}
</DialogHeader>

{isBulkMode && (
<BlockStack
gap="1"
className="rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground"
>
<Paragraph size="xs" weight="semibold">
Bulk mode
</Paragraph>
<Paragraph size="xs">
Enter comma-separated values for bulk inputs (e.g.{" "}
<Paragraph as="span" size="xs" className="font-mono">
A, B, C
</Paragraph>
). Each value creates a separate run. Non-bulk inputs reuse their
single value across all runs. If multiple inputs are set to bulk,
they must have the same number of values.
</Paragraph>
</BlockStack>
)}

{hasInputs && (
<ScrollArea className="max-h-[60vh] pr-4 w-full">
<BlockStack gap="4" className="p-1">
{inputs.map((input) => {
const highlightVersion = highlightedArgs.get(input.name);
const isBulkEnabled = bulkInputNames.has(input.name);
const currentValue = taskArguments[input.name];
const bulkValueCount =
isBulkEnabled && typeof currentValue === "string"
? parseBulkValues(currentValue).length
: 0;

return (
<ArgumentField
key={`${input.name}-${highlightVersion ?? "static"}`}
input={input}
value={taskArguments[input.name]}
value={currentValue}
onChange={handleValueChange}
isHighlighted={highlightVersion !== undefined}
isBulkEnabled={isBulkEnabled}
onBulkToggle={handleBulkToggle}
bulkValueCount={bulkValueCount}
/>
);
})}
Expand All @@ -185,18 +242,39 @@ export const SubmitTaskArgumentsDialog = ({
</Paragraph>
<Textarea
value={runNotes}
onChange={(e) => handleRunNotesChange(e.target.value)}
onChange={(e) => setRunNotes(e.target.value)}
placeholder="Share context about this pipeline run..."
className="text-xs!"
/>
</BlockStack>

{isBulkMode && (
<InlineStack gap="2" align="start" className="px-1">
{hasBulkMismatch ? (
<Paragraph size="xs" tone="critical">
Bulk inputs have different numbers of values. All bulk inputs
must have the same count.
</Paragraph>
) : (
<Paragraph size="xs" tone="subdued">
This will submit{" "}
<Paragraph as="span" size="xs" weight="semibold">
{effectiveRunCount}
</Paragraph>{" "}
{effectiveRunCount === 1 ? "run" : "runs"}.
</Paragraph>
)}
</InlineStack>
)}

<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!isValidToSubmit}>
Submit Run
{isBulkMode && effectiveRunCount > 1
? `Submit ${effectiveRunCount} Runs`
: "Submit Run"}
</Button>
</DialogFooter>
</DialogContent>
Expand Down Expand Up @@ -330,21 +408,27 @@ interface ArgumentFieldProps {
value: ArgumentType | undefined;
onChange: (name: string, value: ArgumentType) => void;
isHighlighted?: boolean;
isBulkEnabled?: boolean;
onBulkToggle?: (name: string, enabled: boolean) => void;
bulkValueCount?: number;
}

const ArgumentField = ({
input,
value,
onChange,
isHighlighted,
isBulkEnabled = false,
onBulkToggle,
bulkValueCount = 0,
}: ArgumentFieldProps) => {
const [isSelectSecretDialogOpen, setIsSelectSecretDialogOpen] =
useState(false);

const isValueSecret = isSecretArgument(value);
const secretName = isValueSecret ? extractSecretName(value) : null;
// For the submit dialog, we only expect string values or SecretArguments
const stringValue = typeof value === "string" ? value : "";
const canBeBulk = !isValueSecret;

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange(input.name, e.target.value);
Expand All @@ -361,7 +445,9 @@ const ArgumentField = ({

const typeLabel = typeSpecToString(input.type);
const isRequired = !input.optional;
const placeholder = input.default ?? "";
const placeholder = isBulkEnabled
? "value1, value2, value3"
: (input.default ?? "");
const hasValidValue =
isValueSecret || Boolean(stringValue) || Boolean(placeholder);

Expand All @@ -374,14 +460,38 @@ const ArgumentField = ({
isHighlighted && "animate-highlight-fade",
)}
>
<InlineStack gap="2" align="start">
<InlineStack gap="2" align="start" className="w-full">
<Paragraph size="sm" className="wrap-break-word">
{input.name}
</Paragraph>
<Paragraph size="xs" tone="subdued" className="truncate">
({typeLabel}
{isRequired ? "*" : ""})
</Paragraph>
<div className="flex-1" />
{canBeBulk && (
<InlineStack gap="1" align="center">
<Label
htmlFor={`bulk-${input.name}`}
Copy link
Collaborator

Choose a reason for hiding this comment

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

bulk-${input.name} is used more than once - consider extracting into a const

className="text-xs text-muted-foreground cursor-pointer"
>
Bulk
</Label>
<Switch
id={`bulk-${input.name}`}
checked={isBulkEnabled}
onCheckedChange={(checked) =>
onBulkToggle?.(input.name, checked)
}
className="scale-75"
Copy link
Collaborator

Choose a reason for hiding this comment

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

perhaps we should add a size prop to our switch component

/>
{isBulkEnabled && bulkValueCount > 0 && (
<Badge variant="secondary" size="xs" shape="rounded">
{bulkValueCount}
</Badge>
)}
</InlineStack>
)}
</InlineStack>

{input.description && (
Expand Down
Loading