refactor(editor): PinnedDataSection — useId select, side-aware placeholder, exhaustive-deps docs
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useId, useState } from "react";
|
||||||
import { useExecution, useExecutions } from "@/api/hooks";
|
import { useExecution, useExecutions } from "@/api/hooks";
|
||||||
import type { NodeExecutionDto } from "@/api/types";
|
import type { NodeExecutionDto } from "@/api/types";
|
||||||
|
|
||||||
@@ -17,17 +17,21 @@ export function PinnedDataSection({
|
|||||||
const [text, setText] = useState<string>(() => stringifyOrEmpty(value));
|
const [text, setText] = useState<string>(() => stringifyOrEmpty(value));
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loadEid, setLoadEid] = useState<string>("");
|
const [loadEid, setLoadEid] = useState<string>("");
|
||||||
|
const selectId = useId();
|
||||||
|
|
||||||
// If the parent's value changes while we're not editing (e.g. after a
|
// If the parent's value changes while we're not editing (e.g. after a
|
||||||
// load-from-execution or a Clear), keep the textarea in sync. We only
|
// load-from-execution or a Clear), keep the textarea in sync. We only
|
||||||
// re-sync when the current text is invalid (let parse-error stand) OR the
|
// re-sync when the current text is invalid (let parse-error stand) OR the
|
||||||
// current text parses to a different value than the parent's.
|
// current text parses to a different value than the parent's.
|
||||||
|
// Depending on `text` here would create a self-triggering loop; we
|
||||||
|
// rely on `safeParse`'s tri-state guard instead.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const parsedCurrent = safeParse(text);
|
const parsedCurrent = safeParse(text);
|
||||||
if (parsedCurrent === undefined) return; // parse-error state — don't overwrite
|
if (parsedCurrent === undefined) return; // parse-error state — don't overwrite
|
||||||
if (canonicalize(parsedCurrent) === canonicalize(value)) return;
|
if (canonicalize(parsedCurrent) === canonicalize(value)) return;
|
||||||
setText(stringifyOrEmpty(value));
|
setText(stringifyOrEmpty(value));
|
||||||
}, [value]); // deliberately not depending on `text`
|
}, [value]);
|
||||||
|
|
||||||
function commit(next: string) {
|
function commit(next: string) {
|
||||||
setText(next);
|
setText(next);
|
||||||
@@ -58,6 +62,15 @@ export function PinnedDataSection({
|
|||||||
|
|
||||||
// Once the selected execution's detail arrives, splice its inputs[0] /
|
// Once the selected execution's detail arrives, splice its inputs[0] /
|
||||||
// outputs[0] for our node into the textarea.
|
// outputs[0] for our node into the textarea.
|
||||||
|
// Deps omit `nodeId`, `side`, and `commit` deliberately:
|
||||||
|
// - `nodeId` and `side` are effectively constant per mount (dialog owns
|
||||||
|
// one node at a time).
|
||||||
|
// - `commit` closes over `onChange`/`setText`/`setError` — all stable
|
||||||
|
// setters, and we WANT the current onChange, so re-capturing per
|
||||||
|
// render is fine.
|
||||||
|
// - Including any of them would cause the effect to re-fire on
|
||||||
|
// unrelated re-renders and clobber user edits.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loadEid || !loadedExec.data) return;
|
if (!loadEid || !loadedExec.data) return;
|
||||||
const row = loadedExec.data.nodes.find((n: NodeExecutionDto) => n.nodeId === nodeId);
|
const row = loadedExec.data.nodes.find((n: NodeExecutionDto) => n.nodeId === nodeId);
|
||||||
@@ -75,11 +88,11 @@ export function PinnedDataSection({
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-xs uppercase text-slate-500">{title}</span>
|
<span className="text-xs uppercase text-slate-500">{title}</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<label className="text-xs text-slate-500" htmlFor="load-from-exec">
|
<label className="text-xs text-slate-500" htmlFor={selectId}>
|
||||||
Load from execution:
|
Load from execution:
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="load-from-exec"
|
id={selectId}
|
||||||
aria-label="Load from execution"
|
aria-label="Load from execution"
|
||||||
value={loadEid}
|
value={loadEid}
|
||||||
onChange={(e) => setLoadEid(e.target.value)}
|
onChange={(e) => setLoadEid(e.target.value)}
|
||||||
@@ -105,7 +118,9 @@ export function PinnedDataSection({
|
|||||||
aria-label={title}
|
aria-label={title}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => commit(e.target.value)}
|
onChange={(e) => commit(e.target.value)}
|
||||||
placeholder="(none — this node runs on real upstream data)"
|
placeholder={side === "outputs"
|
||||||
|
? "(none — this node emits real data when run)"
|
||||||
|
: "(none — this node runs on real upstream data)"}
|
||||||
rows={10}
|
rows={10}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-xs font-mono leading-5 resize-none"
|
className="w-full rounded bg-slate-950 border border-slate-800 px-3 py-2 text-xs font-mono leading-5 resize-none"
|
||||||
@@ -124,6 +139,12 @@ function stringifyOrEmpty(v: unknown | null): string {
|
|||||||
return JSON.stringify(v, null, 2);
|
return JSON.stringify(v, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tri-state:
|
||||||
|
// `null` → empty / whitespace-only text (semantic: "no pin")
|
||||||
|
// `undefined` → text is non-empty but JSON.parse threw (parse-error state)
|
||||||
|
// otherwise → the parsed value
|
||||||
|
// The parent-sync effect distinguishes these to avoid overwriting an
|
||||||
|
// in-progress parse error.
|
||||||
function safeParse(s: string): unknown | undefined {
|
function safeParse(s: string): unknown | undefined {
|
||||||
const t = s.trim();
|
const t = s.trim();
|
||||||
if (t === "") return null;
|
if (t === "") return null;
|
||||||
@@ -131,6 +152,10 @@ function safeParse(s: string): unknown | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Very small structural fingerprint. Sufficient for parent-vs-text equality.
|
// Very small structural fingerprint. Sufficient for parent-vs-text equality.
|
||||||
|
// Note: order-sensitive by design — same-shape objects with different key
|
||||||
|
// insertion order will look distinct. In practice the parent hands us back
|
||||||
|
// either the same object ref or a re-serialization of what we just parsed
|
||||||
|
// (same key order), so this doesn't cause overwrite churn.
|
||||||
function canonicalize(v: unknown): string {
|
function canonicalize(v: unknown): string {
|
||||||
return JSON.stringify(v);
|
return JSON.stringify(v);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user