conductor/frontend/src/components/Preview/PreviewComponent.tsx
2026-07-18 10:23:13 -04:00

423 lines
13 KiB
TypeScript

import React from 'react';
import type { CanvasComponent, DropdownOption, TableColumn, TableRow } from '../../types/project';
import type { ComponentRuntimeState } from './usePreviewRuntime';
import { labelDisplayValue } from './bindingUtils';
import styles from './PreviewComponent.module.css';
// ── Per-type renderers ────────────────────────────────────────────────────────
type LabelRendererProps = {
/** Configured/design-time label text from component properties. */
configuredLabel: string;
/** Runtime state — a defined value overrides configuredLabel. */
runtimeState?: ComponentRuntimeState;
};
function LabelRenderer({ configuredLabel, runtimeState }: LabelRendererProps): React.ReactElement {
// A defined runtime value overrides the configured label.
// Falsy runtime values (false, 0, "", null) must still override — only
// undefined means "no runtime value set".
const display =
runtimeState?.value !== undefined
? labelDisplayValue(runtimeState.value)
: configuredLabel;
return <span className={styles.label}>{display || '\u00a0'}</span>;
}
type ButtonRendererProps = {
label: string;
isLoading: boolean;
error?: string;
onClick: () => void;
};
function ButtonRenderer({ label, isLoading, error, onClick }: ButtonRendererProps): React.ReactElement {
return (
<div className={styles.buttonWrapper}>
<button
className={styles.button}
type="button"
disabled={isLoading}
onClick={onClick}
>
{isLoading ? <span className={styles.spinner} aria-hidden="true" /> : null}
{isLoading ? 'Running…' : (label || 'Button')}
</button>
{error && (
<div className={styles.buttonError} role="alert" title={error}>
{error}
</div>
)}
</div>
);
}
function TextInputRenderer({
label,
placeholder,
value,
disabled,
onChange,
}: {
label: string;
placeholder: string;
value: string;
disabled: boolean;
onChange: (value: string) => void;
}): React.ReactElement {
return (
<div className={styles.inputWrapper}>
{label && <label className={styles.inputLabel}>{label}</label>}
<input
className={styles.input}
type="text"
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
// ── Dropdown renderer ─────────────────────────────────────────────────────────
type DropdownRendererProps = {
label: string;
placeholder: string;
options: DropdownOption[];
/** Current selection: runtimeState.value if set, else configured properties.value */
selectedValue: string;
disabled: boolean;
onChange: (value: string) => void;
};
function DropdownRenderer({
label,
placeholder,
options,
selectedValue,
disabled,
onChange,
}: DropdownRendererProps): React.ReactElement {
return (
<div className={styles.dropdownWrapper}>
{label && <label className={styles.dropdownLabel}>{label}</label>}
<select
className={styles.dropdown}
value={selectedValue}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
>
{/* Disabled placeholder option — shown when no value selected */}
<option value="" disabled>
{placeholder || 'Select an option'}
</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
}
// ── Table renderer ────────────────────────────────────────────────────────────
/** Converts a cell value to a display string per the step spec. */
function renderTableCellValue(value: unknown): string {
if (value === undefined) return '';
if (value === null) return 'null';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value);
} catch {
return '[serialisation error]';
}
}
type TableRendererProps = {
label: string;
columns: TableColumn[];
rows: TableRow[];
disabled: boolean;
selectedIndex?: number;
onRowSelect: (index: number, row: TableRow) => void;
};
function TableRenderer({
label,
columns,
rows,
disabled,
selectedIndex,
onRowSelect,
}: TableRendererProps): React.ReactElement {
const effectiveCols: TableColumn[] =
columns.length > 0
? columns
: rows.length > 0
? Object.keys(rows[0]).map((k) => ({ key: k, header: k }))
: [];
return (
<div className={[styles.tableWrapper, disabled ? styles.tableDisabled : ''].join(' ')}>
{label && <div className={styles.tableLabel}>{label}</div>}
{effectiveCols.length === 0 && rows.length === 0 ? (
<div className={styles.tableEmpty}>No data</div>
) : (
<div className={styles.tableScroll}>
<table className={styles.table}>
<thead>
<tr>
{effectiveCols.map((col) => (
<th key={col.key} className={styles.tableTh}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td
className={styles.tableTd}
colSpan={effectiveCols.length || 1}
style={{ textAlign: 'center', color: '#8b949e' }}
>
No rows
</td>
</tr>
) : (
rows.map((row, ri) => (
<tr
key={ri}
className={[
styles.tableRow,
ri === selectedIndex ? styles.tableRowSelected : '',
disabled ? '' : styles.tableRowClickable,
].join(' ')}
onClick={() => { if (!disabled) onRowSelect(ri, row); }}
>
{effectiveCols.map((col) => (
<td key={col.key} className={styles.tableTd}>
{renderTableCellValue(row[col.key])}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
);
}
type JsonViewerRendererProps = {
defaultValue: string;
runtimeState?: ComponentRuntimeState;
};
function JsonViewerRenderer({ defaultValue, runtimeState }: JsonViewerRendererProps): React.ReactElement {
// Loading state
if (runtimeState?.loading) {
return (
<div className={styles.jsonViewer}>
<div className={styles.jsonStatus}>
<span className={styles.spinner} aria-hidden="true" />
Waiting for response
</div>
</div>
);
}
// Error state
if (runtimeState?.error) {
return (
<div className={styles.jsonViewer}>
<div className={styles.jsonError} role="alert">
<strong>Error</strong>
<pre className={styles.jsonErrorPre}>{runtimeState.error}</pre>
</div>
</div>
);
}
// Runtime value from a completed action call
if (runtimeState?.value !== undefined) {
const display = typeof runtimeState.value === 'string'
? runtimeState.value
: JSON.stringify(runtimeState.value, null, 2);
return (
<div className={styles.jsonViewer}>
<pre className={styles.jsonPre}>{display}</pre>
</div>
);
}
// Design-time defaultValue (no action has fired yet)
let display: string;
if (defaultValue) {
try {
display = JSON.stringify(JSON.parse(defaultValue), null, 2);
} catch {
display = defaultValue;
}
} else {
display = JSON.stringify(
{ status: 'No data', hint: 'Click a bound button to populate this viewer.' },
null,
2,
);
}
return (
<div className={styles.jsonViewer}>
<pre className={styles.jsonPre}>{display}</pre>
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
type PreviewComponentProps = {
component: CanvasComponent;
runtimeState?: ComponentRuntimeState;
isLoading: boolean;
onButtonClick: (componentId: string) => void;
onTextInputChange: (componentId: string, value: string) => void;
onDropdownChange: (componentId: string, value: string) => void;
onTableRowSelect: (componentId: string, index: number, row: Record<string, unknown>) => void;
};
function PreviewComponent({
component,
runtimeState,
isLoading,
onButtonClick,
onTextInputChange,
onDropdownChange,
onTableRowSelect,
}: PreviewComponentProps): React.ReactElement | null {
const { type, position, size, properties } = component;
// Respect the visibility flag — hidden components are not rendered
if (properties.visible === false) return null;
const label = typeof properties.label === 'string' ? properties.label : '';
const placeholder = typeof properties.placeholder === 'string' ? properties.placeholder : '';
const defaultValue = typeof properties.defaultValue === 'string' ? properties.defaultValue : '';
const disabled = properties.disabled === true;
return (
<div
className={styles.wrapper}
style={{
left: position.x,
top: position.y,
width: size.width,
...(type === 'TextInput' || type === 'JsonViewer' || type === 'Dropdown' || type === 'Table'
? { minHeight: size.height }
: { height: size.height }),
}}
>
{type === 'Label' && (
<LabelRenderer configuredLabel={label} runtimeState={runtimeState} />
)}
{type === 'Button' && (
<ButtonRenderer
label={label}
isLoading={isLoading}
error={runtimeState?.error}
onClick={() => onButtonClick(component.id)}
/>
)}
{type === 'TextInput' && (
<TextInputRenderer
label={label}
placeholder={placeholder}
value={runtimeState?.textValue ?? defaultValue}
disabled={disabled}
onChange={(val) => onTextInputChange(component.id, val)}
/>
)}
{type === 'JsonViewer' && (
<JsonViewerRenderer
defaultValue={defaultValue}
runtimeState={runtimeState}
/>
)}
{type === 'Table' && (() => {
const rawColumns = properties.columns;
const configuredColumns: TableColumn[] = Array.isArray(rawColumns)
? (rawColumns as TableColumn[]).filter(
(c) => c && typeof c.key === 'string' && typeof c.header === 'string',
)
: [];
const rawRows = properties.rows;
const configuredRows: TableRow[] = Array.isArray(rawRows)
? (rawRows as TableRow[]).filter(
(r) => r !== null && typeof r === 'object' && !Array.isArray(r),
)
: [];
// Runtime rows override configured rows when defined (including an empty array).
// undefined means "no runtime rows yet" — fall back to configured rows.
const effectiveRows: TableRow[] =
runtimeState?.rows !== undefined ? runtimeState.rows : configuredRows;
return (
<TableRenderer
label={label}
columns={configuredColumns}
rows={effectiveRows}
disabled={disabled}
selectedIndex={runtimeState?.selectedIndex}
onRowSelect={(idx, row) => onTableRowSelect(component.id, idx, row)}
/>
);
})()}
{type === 'Dropdown' && (() => {
// Configured options from design-time properties
const rawConfiguredOptions = properties.options;
const configuredOptions: DropdownOption[] = Array.isArray(rawConfiguredOptions)
? (rawConfiguredOptions as DropdownOption[]).filter(
(o) => o && typeof o.label === 'string' && typeof o.value === 'string',
)
: [];
// Runtime options override configured options when defined.
// An empty runtime array is meaningful — do not fall back.
const effectiveOptions: DropdownOption[] =
runtimeState?.options !== undefined
? runtimeState.options
: configuredOptions;
// configured value from properties (string)
const configuredValue = typeof properties.value === 'string' ? properties.value : '';
// runtime selection overrides configured value (undefined means no runtime selection yet)
const selectedValue =
runtimeState?.value !== undefined
? String(runtimeState.value)
: configuredValue;
return (
<DropdownRenderer
label={label}
placeholder={placeholder}
options={effectiveOptions}
selectedValue={selectedValue}
disabled={disabled}
onChange={(val) => onDropdownChange(component.id, val)}
/>
);
})()}
</div>
);
}
export default PreviewComponent;