mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
/**
|
|
* RunItemDataBrowser (Phase 6)
|
|
* ----------------------------
|
|
*
|
|
* Per-item data browser for a single run step's output, extracted from
|
|
* {@link FlowRunInspectorDrawer} to keep both files small. Renders the n8n
|
|
* signature **table ⟷ JSON** toggle over the step's normalized output items
|
|
* (see `lib/flows/runItems.ts`):
|
|
*
|
|
* - **Table view** — one row per item, columns derived from the union of the
|
|
* items' `json` keys. Long cell values truncate (full value on hover via
|
|
* `title`); the whole table scrolls horizontally when wide.
|
|
* - **JSON view** — the items' `json` payloads pretty-printed, vertically
|
|
* scrollable.
|
|
*
|
|
* Binary attachments are never inlined — they render as placeholder chips
|
|
* (name / MIME). When an output item carries a resolved `paired_item` and the
|
|
* caller supplied the step's `inputItems`, a "Source" affordance reveals the
|
|
* input item that produced it; absent pairing, no affordance is offered.
|
|
*/
|
|
import debug from 'debug';
|
|
import { useMemo, useState } from 'react';
|
|
|
|
import {
|
|
cellValue,
|
|
collectColumns,
|
|
type FlowRunItem,
|
|
formatCell,
|
|
formatJson,
|
|
hasObjectRows,
|
|
} from '../../lib/flows/runItems';
|
|
import { useT } from '../../lib/i18n/I18nContext';
|
|
|
|
const log = debug('flows:run-item-data-browser');
|
|
|
|
/** Cap a single table cell's rendered text so one huge value can't blow out the row. */
|
|
const MAX_CELL_CHARS = 200;
|
|
|
|
type ViewMode = 'table' | 'json';
|
|
|
|
function truncate(text: string): string {
|
|
return text.length > MAX_CELL_CHARS ? `${text.slice(0, MAX_CELL_CHARS)}…` : text;
|
|
}
|
|
|
|
interface BinaryChipsProps {
|
|
binary: FlowRunItem['binary'];
|
|
testId: string;
|
|
}
|
|
|
|
/** Placeholder chips for an item's binary attachments — metadata only, no bytes. */
|
|
function BinaryChips({ binary, testId }: BinaryChipsProps) {
|
|
const { t } = useT();
|
|
if (binary.length === 0) return null;
|
|
return (
|
|
<div className="flex flex-wrap gap-1" data-testid={testId}>
|
|
{binary.map(ref => (
|
|
<span
|
|
key={ref.key}
|
|
className="inline-flex items-center gap-1 rounded-md border border-line bg-surface px-1.5 py-0.5 text-[10px] font-medium text-content-muted"
|
|
title={ref.mimeType ?? undefined}>
|
|
<span aria-hidden>📎</span>
|
|
<span className="font-mono">{ref.fileName ?? ref.key}</span>
|
|
<span className="rounded bg-surface-muted px-1 text-[9px] uppercase text-content-faint">
|
|
{ref.mimeType ?? t('flowRuns.inspector.binaryLabel')}
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface Props {
|
|
/** Normalized output items of the step being inspected. */
|
|
items: FlowRunItem[];
|
|
/**
|
|
* Normalized items of the step's *input* (typically the upstream step's
|
|
* output). When present, output items carrying a resolved `paired_item` gain
|
|
* a "Source" affordance that reveals the input item at that index. Omitted →
|
|
* no pairing affordance is offered.
|
|
*/
|
|
inputItems?: FlowRunItem[];
|
|
/** Stable prefix for `data-testid`s so multiple browsers on one screen don't collide. */
|
|
testIdPrefix: string;
|
|
}
|
|
|
|
export function RunItemDataBrowser({ items, inputItems, testIdPrefix }: Props) {
|
|
const { t } = useT();
|
|
const [view, setView] = useState<ViewMode>('table');
|
|
// Which output row currently has its paired source input revealed (single-open).
|
|
const [revealedSource, setRevealedSource] = useState<number | null>(null);
|
|
|
|
const columns = useMemo(() => collectColumns(items), [items]);
|
|
const useColumns = hasObjectRows(items) && columns.length > 0;
|
|
const showActions = useMemo(
|
|
() =>
|
|
items.some(
|
|
item => item.binary.length > 0 || (item.pairedIndex !== null && inputItems !== undefined)
|
|
),
|
|
[items, inputItems]
|
|
);
|
|
|
|
const jsonText = useMemo(() => formatJson(items.map(item => item.json)), [items]);
|
|
|
|
const totalColSpan = 1 + (useColumns ? columns.length : 1) + (showActions ? 1 : 0);
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<p className="text-[11px] italic text-content-faint" data-testid={`${testIdPrefix}-no-items`}>
|
|
{t('flowRuns.inspector.noItems')}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
const toggleSource = (index: number, pairedIndex: number) => {
|
|
const next = revealedSource === index ? null : index;
|
|
log(
|
|
'toggleSource: prefix=%s row=%d paired=%d open=%s',
|
|
testIdPrefix,
|
|
index,
|
|
pairedIndex,
|
|
next !== null
|
|
);
|
|
setRevealedSource(next);
|
|
};
|
|
|
|
return (
|
|
<div data-testid={`${testIdPrefix}-data-browser`}>
|
|
{/* Header: view toggle + item count. */}
|
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
|
<div
|
|
className="inline-flex overflow-hidden rounded-md border border-line"
|
|
role="group"
|
|
aria-label={t('flowRuns.inspector.dataViewLabel')}>
|
|
<button
|
|
type="button"
|
|
data-testid={`${testIdPrefix}-view-table`}
|
|
aria-pressed={view === 'table'}
|
|
onClick={() => setView('table')}
|
|
className={`px-2 py-0.5 text-[11px] font-medium ${
|
|
view === 'table'
|
|
? 'bg-ocean-500 text-white'
|
|
: 'bg-surface text-content-muted hover:bg-surface-hover'
|
|
}`}>
|
|
{t('flowRuns.inspector.dataTable')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid={`${testIdPrefix}-view-json`}
|
|
aria-pressed={view === 'json'}
|
|
onClick={() => setView('json')}
|
|
className={`border-l border-line px-2 py-0.5 text-[11px] font-medium ${
|
|
view === 'json'
|
|
? 'bg-ocean-500 text-white'
|
|
: 'bg-surface text-content-muted hover:bg-surface-hover'
|
|
}`}>
|
|
{t('flowRuns.inspector.dataJson')}
|
|
</button>
|
|
</div>
|
|
<span className="text-[10px] text-content-faint" data-testid={`${testIdPrefix}-item-count`}>
|
|
{t('flowRuns.inspector.itemCount').replace('{count}', String(items.length))}
|
|
</span>
|
|
</div>
|
|
|
|
{view === 'json' ? (
|
|
<pre
|
|
data-testid={`${testIdPrefix}-json`}
|
|
className="max-h-72 overflow-auto whitespace-pre-wrap break-words rounded bg-surface px-2 py-1.5 font-mono text-[11px] leading-relaxed text-content-secondary">
|
|
{jsonText}
|
|
</pre>
|
|
) : (
|
|
<div
|
|
className="max-h-72 overflow-auto rounded border border-line"
|
|
data-testid={`${testIdPrefix}-table-scroll`}>
|
|
<table
|
|
className="w-full border-collapse text-left text-[11px]"
|
|
data-testid={`${testIdPrefix}-table`}>
|
|
<thead>
|
|
<tr className="bg-surface-muted text-content-muted">
|
|
<th className="border-b border-line px-1.5 py-1 font-medium" aria-hidden />
|
|
{useColumns ? (
|
|
columns.map(column => (
|
|
<th
|
|
key={column}
|
|
className="border-b border-line px-1.5 py-1 font-mono font-medium"
|
|
scope="col">
|
|
{column}
|
|
</th>
|
|
))
|
|
) : (
|
|
<th className="border-b border-line px-1.5 py-1 font-medium" scope="col">
|
|
{t('flowRuns.inspector.dataJson')}
|
|
</th>
|
|
)}
|
|
{showActions && <th className="border-b border-line px-1.5 py-1" aria-hidden />}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((item, index) => {
|
|
const canPair = item.pairedIndex !== null && inputItems !== undefined;
|
|
const sourceItem =
|
|
canPair && item.pairedIndex !== null ? inputItems?.[item.pairedIndex] : undefined;
|
|
const isRevealed = revealedSource === index;
|
|
return (
|
|
<FragmentRow
|
|
key={index}
|
|
item={item}
|
|
index={index}
|
|
columns={columns}
|
|
useColumns={useColumns}
|
|
showActions={showActions}
|
|
canPair={canPair}
|
|
isRevealed={isRevealed}
|
|
sourceItem={sourceItem}
|
|
totalColSpan={totalColSpan}
|
|
testIdPrefix={testIdPrefix}
|
|
onToggleSource={() =>
|
|
item.pairedIndex !== null && toggleSource(index, item.pairedIndex)
|
|
}
|
|
/>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface FragmentRowProps {
|
|
item: FlowRunItem;
|
|
index: number;
|
|
columns: string[];
|
|
useColumns: boolean;
|
|
showActions: boolean;
|
|
canPair: boolean;
|
|
isRevealed: boolean;
|
|
sourceItem: FlowRunItem | undefined;
|
|
totalColSpan: number;
|
|
testIdPrefix: string;
|
|
onToggleSource: () => void;
|
|
}
|
|
|
|
function FragmentRow({
|
|
item,
|
|
index,
|
|
columns,
|
|
useColumns,
|
|
showActions,
|
|
canPair,
|
|
isRevealed,
|
|
sourceItem,
|
|
totalColSpan,
|
|
testIdPrefix,
|
|
onToggleSource,
|
|
}: FragmentRowProps) {
|
|
const { t } = useT();
|
|
return (
|
|
<>
|
|
<tr
|
|
data-testid={`${testIdPrefix}-row-${index}`}
|
|
className="border-b border-line last:border-b-0 align-top">
|
|
<th scope="row" className="px-1.5 py-1 text-left font-mono font-normal text-content-faint">
|
|
{index + 1}
|
|
</th>
|
|
{useColumns ? (
|
|
columns.map(column => {
|
|
const text = formatCell(cellValue(item, column));
|
|
return (
|
|
<td
|
|
key={column}
|
|
className="max-w-[16rem] truncate px-1.5 py-1 font-mono text-content-secondary"
|
|
title={text || undefined}>
|
|
{truncate(text)}
|
|
</td>
|
|
);
|
|
})
|
|
) : (
|
|
<td
|
|
className="max-w-[16rem] truncate px-1.5 py-1 font-mono text-content-secondary"
|
|
title={formatCell(item.json) || undefined}>
|
|
{truncate(formatCell(item.json))}
|
|
</td>
|
|
)}
|
|
{showActions && (
|
|
<td className="whitespace-nowrap px-1.5 py-1">
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
<BinaryChips binary={item.binary} testId={`${testIdPrefix}-binary-${index}`} />
|
|
{canPair && (
|
|
<button
|
|
type="button"
|
|
data-testid={`${testIdPrefix}-source-toggle-${index}`}
|
|
aria-pressed={isRevealed}
|
|
onClick={onToggleSource}
|
|
className="rounded border border-line px-1.5 py-0.5 text-[10px] font-medium text-content-muted hover:bg-surface-hover">
|
|
{isRevealed
|
|
? t('flowRuns.inspector.hideSource')
|
|
: t('flowRuns.inspector.showSource')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
{canPair && isRevealed && (
|
|
<tr data-testid={`${testIdPrefix}-source-${index}`}>
|
|
<td colSpan={totalColSpan} className="bg-surface-muted px-1.5 py-1.5">
|
|
<div className="text-[10px] font-semibold uppercase tracking-wide text-content-faint">
|
|
{t('flowRuns.inspector.sourceInputTitle')}
|
|
</div>
|
|
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded bg-surface px-2 py-1.5 font-mono text-[11px] leading-relaxed text-content-secondary">
|
|
{sourceItem ? formatJson(sourceItem.json) : t('flowRuns.inspector.emptyValue')}
|
|
</pre>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default RunItemDataBrowser;
|