423 lines
13 KiB
TypeScript
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;
|