refactor(workspace): restore yaml exports and align docker builds
- return scenario export payloads as yaml to match existing workflows - harden step reordering flow for drag-and-drop and one-based ui labels - switch server container to workspace-lockfile installs and root ignore rules
This commit is contained in:
@@ -27,9 +27,13 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
const contentLength = res.headers.get('content-length');
|
||||
if (res.status === 204 || contentLength === '0') return undefined as T;
|
||||
const contentType = res.headers.get('content-type')?.toLowerCase() ?? '';
|
||||
const text = await res.text();
|
||||
if (!text) return undefined as T;
|
||||
return JSON.parse(text) as T;
|
||||
if (contentType.includes('application/json')) {
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
return text as T;
|
||||
}
|
||||
|
||||
// ── Credentials ───────────────────────────────────────────────────────────────
|
||||
@@ -185,7 +189,7 @@ export const scenarios = {
|
||||
): Promise<PaginatedResponse<ScenarioRun & { stepRuns: ScenarioRunStep[] }>> {
|
||||
return request(`/scenarios/${scenarioId}/runs?page=${page}&limit=${limit}`);
|
||||
},
|
||||
exportScenario(id: string): Promise<unknown> {
|
||||
exportScenario(id: string): Promise<string> {
|
||||
return request(`/scenarios/${id}/export`);
|
||||
},
|
||||
importScenario(payload: unknown): Promise<Scenario> {
|
||||
@@ -247,7 +251,6 @@ export const scenarioCredentials = {
|
||||
// ── Scenario Steps ────────────────────────────────────────────────────────────
|
||||
export interface CreateStepPayload {
|
||||
title?: string;
|
||||
order: number;
|
||||
execCode?: string;
|
||||
validateCode?: string;
|
||||
}
|
||||
|
||||
@@ -145,6 +145,35 @@
|
||||
color: var(--color-link-hover);
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: grab;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dragHandle:hover {
|
||||
background: color-mix(in srgb, var(--color-secondary) 18%, transparent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.dragHandle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.dragHandleDisabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dragOverRow {
|
||||
background: color-mix(in srgb, var(--color-primary) 16%, transparent) !important;
|
||||
}
|
||||
|
||||
.envCardHeader button:hover {
|
||||
background: color-mix(in srgb, currentColor 15%, transparent) !important;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { scenarios, steps } from '../../api';
|
||||
import type { Scenario } from '../../api';
|
||||
import type { CreateStepPayload } from '../../api/client';
|
||||
import { Breadcrumbs, Button, Card, Input, CodeEditor } from '../../ui';
|
||||
import styles from '../Page.module.css';
|
||||
|
||||
@@ -13,11 +12,9 @@ export function CreateStepPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||
const [order, setOrder] = useState('0');
|
||||
const [title, setTitle] = useState('');
|
||||
const [execCode, setExecCode] = useState('');
|
||||
const [validateCode, setValidateCode] = useState('');
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof CreateStepPayload, string>>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -29,21 +26,12 @@ export function CreateStepPage() {
|
||||
.catch(() => null);
|
||||
}, [id]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const next: typeof errors = {};
|
||||
if (isNaN(Number(order)) || Number(order) < 0) next.order = t('steps.form_order_invalid');
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await steps.create(id!, {
|
||||
order: Number(order),
|
||||
title: title.trim() || undefined,
|
||||
execCode: execCode.trim() || undefined,
|
||||
validateCode: validateCode.trim() || undefined,
|
||||
@@ -76,14 +64,6 @@ export function CreateStepPage() {
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
label={t('steps.form_order')}
|
||||
type="number"
|
||||
min={0}
|
||||
value={order}
|
||||
onChange={(e) => setOrder(e.target.value)}
|
||||
error={errors.order}
|
||||
/>
|
||||
<Input
|
||||
label={t('steps.form_title')}
|
||||
placeholder={t('steps.form_title_placeholder')}
|
||||
@@ -92,21 +72,11 @@ export function CreateStepPage() {
|
||||
/>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.fieldLabel}>{t('steps.form_exec_code')}</label>
|
||||
<CodeEditor
|
||||
value={execCode}
|
||||
onChange={setExecCode}
|
||||
rows={6}
|
||||
placeholder={t('steps.form_exec_code_placeholder')}
|
||||
/>
|
||||
<CodeEditor value={execCode} onChange={setExecCode} rows={6} />
|
||||
</div>
|
||||
<div className={styles.formField}>
|
||||
<label className={styles.fieldLabel}>{t('steps.form_validate_code')}</label>
|
||||
<CodeEditor
|
||||
value={validateCode}
|
||||
onChange={setValidateCode}
|
||||
rows={6}
|
||||
placeholder={t('steps.form_validate_code_placeholder')}
|
||||
/>
|
||||
<CodeEditor value={validateCode} onChange={setValidateCode} rows={6} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,11 +13,9 @@ export function EditStepPage() {
|
||||
|
||||
const [scenario, setScenario] = useState<Scenario | null>(null);
|
||||
const [step, setStep] = useState<ScenarioStep | null>(null);
|
||||
const [order, setOrder] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [execCode, setExecCode] = useState('');
|
||||
const [validateCode, setValidateCode] = useState('');
|
||||
const [orderError, setOrderError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -28,7 +26,6 @@ export function EditStepPage() {
|
||||
.then(([sc, st]) => {
|
||||
setScenario(sc);
|
||||
setStep(st);
|
||||
setOrder(String(st.order));
|
||||
setTitle(st.title ?? '');
|
||||
setExecCode(st.execCode ?? '');
|
||||
setValidateCode(st.validateCode ?? '');
|
||||
@@ -37,23 +34,12 @@ export function EditStepPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, stepId]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
let valid = true;
|
||||
if (isNaN(Number(order)) || Number(order) < 0) {
|
||||
setOrderError(t('steps.form_order_invalid'));
|
||||
valid = false;
|
||||
}
|
||||
return valid;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await steps.update(id!, stepId!, {
|
||||
order: Number(order),
|
||||
title: title.trim() || undefined,
|
||||
execCode: execCode.trim() || undefined,
|
||||
validateCode: validateCode.trim() || undefined,
|
||||
@@ -76,7 +62,7 @@ export function EditStepPage() {
|
||||
label: scenario?.name ?? `#${id}`,
|
||||
onClick: () => navigate(`/scenarios/${id}`),
|
||||
},
|
||||
{ label: t('steps.edit_title', { order: step?.order ?? stepId }) },
|
||||
{ label: t('steps.edit_title', { order: step ? step.order + 1 : stepId }) },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -88,17 +74,6 @@ export function EditStepPage() {
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<Card className={styles.formCardFull}>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
label={t('steps.form_order')}
|
||||
type="number"
|
||||
min={0}
|
||||
value={order}
|
||||
onChange={(e) => {
|
||||
setOrder(e.target.value);
|
||||
setOrderError('');
|
||||
}}
|
||||
error={orderError || undefined}
|
||||
/>
|
||||
<Input
|
||||
label={t('steps.form_title')}
|
||||
placeholder={t('steps.form_title_placeholder')}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Play, Pencil, Plus, History, Trash2, Upload } from 'lucide-react';
|
||||
import { Play, Pencil, Plus, History, Trash2, Upload, GripVertical } from 'lucide-react';
|
||||
import { stringify as yamlStringify } from 'yaml';
|
||||
import { scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api';
|
||||
import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api';
|
||||
@@ -37,10 +37,13 @@ export function ScenarioDetailPage() {
|
||||
const [addCredError, setAddCredError] = useState('');
|
||||
const [addAliasError, setAddAliasError] = useState('');
|
||||
const [addCredSaving, setAddCredSaving] = useState(false);
|
||||
const [draggedStepId, setDraggedStepId] = useState<string | null>(null);
|
||||
const [dragOverStepId, setDragOverStepId] = useState<string | null>(null);
|
||||
const [reorderingSteps, setReorderingSteps] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
scenarios
|
||||
void scenarios
|
||||
.get(id)
|
||||
.then((s) => {
|
||||
setScenario(s);
|
||||
@@ -54,6 +57,13 @@ export function ScenarioDetailPage() {
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
const reloadScenario = async () => {
|
||||
if (!id) return;
|
||||
const s = await scenarios.get(id);
|
||||
setScenario(s);
|
||||
setScenarioCreds(s.scenarioCredentials ?? []);
|
||||
};
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!scenario) return;
|
||||
const run = await scenarios.run(scenario.id);
|
||||
@@ -69,7 +79,8 @@ export function ScenarioDetailPage() {
|
||||
const handleExport = async () => {
|
||||
if (!scenario) return;
|
||||
const data = await scenarios.exportScenario(scenario.id);
|
||||
const blob = new Blob([yamlStringify(data)], { type: 'application/yaml' });
|
||||
const yamlContent = typeof data === 'string' ? data : yamlStringify(data);
|
||||
const blob = new Blob([yamlContent], { type: 'application/yaml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -80,9 +91,21 @@ export function ScenarioDetailPage() {
|
||||
|
||||
const handleDeleteStep = async (stepId: string) => {
|
||||
await steps.remove(id!, stepId);
|
||||
setScenario((prev) =>
|
||||
prev ? { ...prev, steps: prev.steps.filter((s) => s.id !== stepId) } : prev,
|
||||
);
|
||||
await reloadScenario();
|
||||
};
|
||||
|
||||
const handleMoveStep = async (fromStepId: string, toStepId: string) => {
|
||||
if (!scenario || reorderingSteps) return;
|
||||
setReorderingSteps(true);
|
||||
try {
|
||||
const orderedSteps = [...scenario.steps].sort((a, b) => a.order - b.order);
|
||||
const toIndex = orderedSteps.findIndex((step) => step.id === toStepId);
|
||||
if (toIndex < 0) return;
|
||||
await steps.update(id!, fromStepId, { order: toIndex });
|
||||
await reloadScenario();
|
||||
} finally {
|
||||
setReorderingSteps(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCredential = async (e: React.FormEvent) => {
|
||||
@@ -120,7 +143,45 @@ export function ScenarioDetailPage() {
|
||||
};
|
||||
|
||||
const stepColumns: TableColumn<ScenarioStep>[] = [
|
||||
{ key: 'order', header: t('scenarios.step_order'), render: (s) => s.order, width: 60 },
|
||||
{
|
||||
key: 'drag',
|
||||
header: '',
|
||||
width: 44,
|
||||
align: 'center',
|
||||
render: (s) => (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Drag to reorder step"
|
||||
className={[styles.dragHandle, reorderingSteps ? styles.dragHandleDisabled : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
draggable={!reorderingSteps}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onDragStart={(e) => {
|
||||
if (reorderingSteps) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
setDraggedStepId(s.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', s.id);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggedStepId(null);
|
||||
setDragOverStepId(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'order', header: t('scenarios.step_order'), render: (s) => s.order + 1, width: 60 },
|
||||
{
|
||||
key: 'title',
|
||||
header: t('scenarios.step_title'),
|
||||
@@ -256,7 +317,6 @@ export function ScenarioDetailPage() {
|
||||
items={[
|
||||
{ term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
|
||||
{ term: t('scenarios.field_name'), detail: scenario.name },
|
||||
{ term: t('scenarios.field_steps'), detail: scenario.steps.length },
|
||||
{
|
||||
term: t('scenarios.field_created'),
|
||||
detail: <Timestamp value={scenario.createdAt} />,
|
||||
@@ -284,6 +344,30 @@ export function ScenarioDetailPage() {
|
||||
rowKey={(s) => s.id}
|
||||
loading={false}
|
||||
emptyMessage=""
|
||||
getRowProps={(s) => ({
|
||||
className: dragOverStepId === s.id ? styles.dragOverRow : undefined,
|
||||
onDragOver: (e) => {
|
||||
if (reorderingSteps) return;
|
||||
e.preventDefault();
|
||||
if (draggedStepId && draggedStepId !== s.id) {
|
||||
setDragOverStepId(s.id);
|
||||
}
|
||||
},
|
||||
onDragLeave: () => {
|
||||
if (dragOverStepId === s.id) {
|
||||
setDragOverStepId(null);
|
||||
}
|
||||
},
|
||||
onDrop: async (e) => {
|
||||
if (reorderingSteps) return;
|
||||
e.preventDefault();
|
||||
const fromId = draggedStepId ?? e.dataTransfer.getData('text/plain');
|
||||
setDragOverStepId(null);
|
||||
setDraggedStepId(null);
|
||||
if (!fromId || fromId === s.id) return;
|
||||
await handleMoveStep(fromId, s.id);
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useState, type HTMLAttributes, type MouseEvent, type ReactNode } from 'react';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import styles from './Table.module.css';
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface TableProps<T> {
|
||||
pageSizeOptions?: number[];
|
||||
/** Called when a row is clicked */
|
||||
onRowClick?: (row: T) => void;
|
||||
/** Optional per-row props, useful for drag-and-drop and row-level attributes */
|
||||
getRowProps?: (row: T) => HTMLAttributes<HTMLTableRowElement>;
|
||||
}
|
||||
|
||||
export function Table<T>({
|
||||
@@ -35,6 +37,7 @@ export function Table<T>({
|
||||
pageSize: defaultPageSize,
|
||||
pageSizeOptions,
|
||||
onRowClick,
|
||||
getRowProps,
|
||||
}: TableProps<T>) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize ?? 0);
|
||||
@@ -79,26 +82,43 @@ export function Table<T>({
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
visibleData.map((row) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className={[styles.tr, onRowClick ? styles.clickable : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={styles.td}
|
||||
style={{ textAlign: col.align ?? 'left' }}
|
||||
onClick={col.key === 'actions' ? (e) => e.stopPropagation() : undefined}
|
||||
>
|
||||
{col.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
visibleData.map((row) => {
|
||||
const rowProps = getRowProps?.(row);
|
||||
const mergedClassName = [
|
||||
styles.tr,
|
||||
onRowClick ? styles.clickable : '',
|
||||
rowProps?.className ?? '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLTableRowElement>) => {
|
||||
rowProps?.onClick?.(event);
|
||||
if (!event.defaultPrevented) {
|
||||
onRowClick?.(row);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
{...rowProps}
|
||||
className={mergedClassName}
|
||||
onClick={onRowClick || rowProps?.onClick ? handleClick : undefined}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={styles.td}
|
||||
style={{ textAlign: col.align ?? 'left' }}
|
||||
onClick={col.key === 'actions' ? (e) => e.stopPropagation() : undefined}
|
||||
>
|
||||
{col.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user