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
45 changes: 32 additions & 13 deletions frontend/src/components/workflow/ConfigPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { useComponentStore } from '@/store/componentStore';
import { ParameterFieldWrapper } from './ParameterField';
import { WebhookDetails } from './WebhookDetails';
import { SecretSelect } from '@/components/inputs/SecretSelect';
import { DynamicArtifactNameInput } from './DynamicArtifactNameInput';
import type { Node } from 'reactflow';
import type { FrontendNodeData } from '@/schemas/node';
import type { ComponentType, KeyboardEvent } from 'react';
Expand Down Expand Up @@ -913,6 +914,20 @@ export function ConfigPanel({
placeholder={manualPlaceholder}
onChange={(value) => handleInputOverrideChange(input.id, value)}
/>
) : component?.id === 'core.artifact.writer' &&
input.id === 'artifactName' ? (
<DynamicArtifactNameInput
value={manualInputValue}
onChange={(value) => {
if (!value || value === '') {
handleInputOverrideChange(input.id, undefined);
} else {
handleInputOverrideChange(input.id, value);
}
}}
disabled={manualLocked}
placeholder="{{run_id}}-{{timestamp}}"
/>
) : (
<Input
id={`manual-${input.id}`}
Expand All @@ -939,19 +954,23 @@ export function ConfigPanel({
disabled={manualLocked}
/>
)}
{manualLocked ? (
<p className="text-xs text-muted-foreground italic">
Disconnect the port to edit manual input.
</p>
) : (
<p className="text-[10px] text-muted-foreground">
{isBooleanInput
? 'Select a value or clear manual input to require a port connection.'
: isListOfTextInput
? 'Add entries or clear manual input to require a port connection.'
: 'Leave blank to require a port connection.'}
</p>
)}
{/* Skip helper text for DynamicArtifactNameInput as it has its own */}
{!(
component?.id === 'core.artifact.writer' && input.id === 'artifactName'
) &&
(manualLocked ? (
<p className="text-xs text-muted-foreground italic">
Disconnect the port to edit manual input.
</p>
) : (
<p className="text-[10px] text-muted-foreground">
{isBooleanInput
? 'Select a value or clear manual input to require a port connection.'
: isListOfTextInput
? 'Add entries or clear manual input to require a port connection.'
: 'Leave blank to require a port connection.'}
</p>
))}
</div>
)}

Expand Down
77 changes: 77 additions & 0 deletions frontend/src/components/workflow/DynamicArtifactNameInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';

interface DynamicParameter {
placeholder: string;
description: string;
}

const DYNAMIC_PARAMETERS: DynamicParameter[] = [
{ placeholder: '{{run_id}}', description: 'Full workflow run ID' },
{ placeholder: '{{node_id}}', description: 'Component node ID in workflow' },
{ placeholder: '{{timestamp}}', description: 'Unix timestamp (ms)' },
{ placeholder: '{{date}}', description: 'Date (YYYY-MM-DD)' },
{ placeholder: '{{time}}', description: 'Time (HH-MM-SS)' },
];

interface DynamicArtifactNameInputProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}

export function DynamicArtifactNameInput({
value,
onChange,
disabled = false,
placeholder = '{{run_id}}-{{timestamp}}',
}: DynamicArtifactNameInputProps) {
const currentValue = value || '';

const handleInsertPlaceholder = (selectedPlaceholder: string) => {
if (disabled) return;
// Insert at the end of current value
const newValue = currentValue ? `${currentValue}${selectedPlaceholder}` : selectedPlaceholder;
onChange(newValue);
};

return (
<div className="space-y-2">
<Input
type="text"
value={currentValue}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="text-sm font-mono"
disabled={disabled}
/>

<div className="flex items-center gap-2">
<Select disabled={disabled} onValueChange={handleInsertPlaceholder} value="">
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Insert placeholder..." />
</SelectTrigger>
<SelectContent>
{DYNAMIC_PARAMETERS.map((param) => (
<SelectItem key={param.placeholder} value={param.placeholder} className="text-xs">
<span className="font-mono">{param.placeholder}</span>
<span className="ml-2 text-muted-foreground">— {param.description}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>

<p className="text-[10px] text-muted-foreground">
Type directly or select a placeholder to insert. Example: scan-{'{{date}}'}-{'{{time}}'}
</p>
</div>
);
}
102 changes: 98 additions & 4 deletions worker/src/components/core/__tests__/artifact-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('core.artifact.writer component', () => {
const uploadMock = vi.fn().mockResolvedValue({
artifactId: 'artifact-123',
fileId: 'file-123',
name: 'playground-artifact.txt',
name: 'run-log.txt',
destinations: ['run', 'library'],
});

Expand All @@ -44,10 +44,11 @@ describe('core.artifact.writer component', () => {

const executePayload = {
inputs: {
artifactName: 'run-log',
content: 'Hello artifacts!',
},
params: {
fileName: 'run-log.txt',
fileExtension: '.txt',
mimeType: 'text/plain',
saveToRunArtifacts: true,
publishToArtifactLibrary: true,
Expand All @@ -65,16 +66,61 @@ describe('core.artifact.writer component', () => {

expect(result.saved).toBe(true);
expect(result.artifactId).toBe('artifact-123');
expect(result.artifactName).toBe('run-log');
expect(result.fileName).toBe('run-log.txt');
expect(result.destinations).toEqual(['run', 'library']);
});

it('substitutes dynamic placeholders in artifact name', async () => {
if (!component) throw new Error('Component not registered');

const uploadMock = vi.fn().mockResolvedValue({
artifactId: 'artifact-456',
fileId: 'file-456',
name: 'test-artifact.json',
destinations: ['run'],
});

const mockArtifacts: IArtifactService = {
upload: uploadMock,
download: vi.fn(),
};

const context = createExecutionContext({
runId: 'test-run-abc123',
componentRef: 'artifact-writer-2',
artifacts: mockArtifacts,
});

const executePayload = {
inputs: {
artifactName: '{{run_id}}-{{node_id}}',
content: { data: 'test' },
},
params: {
fileExtension: '.json',
mimeType: 'application/json',
saveToRunArtifacts: true,
publishToArtifactLibrary: false,
},
};

const result = await component.execute(executePayload, context);

expect(uploadMock).toHaveBeenCalledTimes(1);
const payload = uploadMock.mock.calls[0][0];
expect(payload.name).toBe('test-run-abc123-artifact-writer-2.json');
expect(result.artifactName).toBe('test-run-abc123-artifact-writer-2');
expect(result.fileName).toBe('test-run-abc123-artifact-writer-2.json');
});

it('skips upload when no destinations are selected', async () => {
if (!component) throw new Error('Component not registered');

const uploadMock = vi.fn();
const context = createExecutionContext({
runId: 'run-2',
componentRef: 'artifact-writer-2',
componentRef: 'artifact-writer-skip',
artifacts: {
upload: uploadMock,
download: vi.fn(),
Expand All @@ -83,10 +129,11 @@ describe('core.artifact.writer component', () => {

const executePayload = {
inputs: {
artifactName: 'noop',
content: 'No destinations',
},
params: {
fileName: 'noop.txt',
fileExtension: '.txt',
saveToRunArtifacts: false,
publishToArtifactLibrary: false,
},
Expand All @@ -97,6 +144,8 @@ describe('core.artifact.writer component', () => {
expect(uploadMock).not.toHaveBeenCalled();
expect(result.saved).toBe(false);
expect(result.artifactId).toBeUndefined();
expect(result.artifactName).toBe('noop');
expect(result.fileName).toBe('noop.txt');
expect(result.destinations).toEqual([]);
});

Expand All @@ -110,9 +159,11 @@ describe('core.artifact.writer component', () => {

const executePayload = {
inputs: {
artifactName: 'test-artifact',
content: 'Need artifacts',
},
params: {
fileExtension: '.txt',
saveToRunArtifacts: true,
publishToArtifactLibrary: false,
},
Expand All @@ -122,4 +173,47 @@ describe('core.artifact.writer component', () => {
'Artifact service is not available',
);
});

it('uses default artifact name template when not provided', async () => {
if (!component) throw new Error('Component not registered');

const uploadMock = vi.fn().mockResolvedValue({
artifactId: 'artifact-default',
fileId: 'file-default',
name: 'default.txt',
destinations: ['run'],
});

const mockArtifacts: IArtifactService = {
upload: uploadMock,
download: vi.fn(),
};

const context = createExecutionContext({
runId: 'run-default-test',
componentRef: 'artifact-writer-default',
artifacts: mockArtifacts,
});

const executePayload = {
inputs: {
// artifactName not provided, should use default template
content: 'Default name test',
},
params: {
fileExtension: '.txt',
saveToRunArtifacts: true,
publishToArtifactLibrary: false,
},
};

const result = await component.execute(executePayload, context);

expect(uploadMock).toHaveBeenCalledTimes(1);
const payload = uploadMock.mock.calls[0][0];
// Should contain run_id and timestamp pattern
expect(payload.name).toMatch(/^run-default-test-\d+\.txt$/);
expect(result.artifactName).toMatch(/^run-default-test-\d+$/);
expect(result.saved).toBe(true);
});
});
Loading