A message shows its shape, and its contents when asked
A record or an artifact reference was serialised into the port row, and the panel widened until the type selects and the buttons beside them were pushed off its edge — a checkpoint reference is 130 characters of digest, and none of them are what you want while wiring a flow. What shows now is what the value *is*: 'artifact · weights.json · 60B', 'record · 3 fields'. A chevron unfolds the whole of it, wrapped, inside the panel it belongs to. A scalar still reads as itself, and scrolls its own overflow into view when it is longer than the room it was given. That behaviour already existed inside the edge inspector; it moves to Common/Marquee so the panel can have it too, and the inspector drops its own copy of the raw-JSON block along with it. The rows are smaller for it: the type select finally fits the word 'artifact', and a port nothing has come through on says so with a dash rather than a sentence — nine ports of 'nothing has come through yet' is a panel of prose about the absence of values. The e2e check asserts both halves: that the summary is what appears, and that the panel is still exactly 400px with the value unfolded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AD8SfVhzXBG2nAfFcVh3iD
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
import { motion } from "motion/react"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** Pixels a second. Slow enough to read, quick enough not to be a wait. */
|
||||||
|
const SPEED = 25
|
||||||
|
/** Long enough to read the start before it moves, and again at the far end. */
|
||||||
|
const DWELL = 1.2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text that scrolls its own overflow into view and back.
|
||||||
|
*
|
||||||
|
* A long message name, or a value that does not fit, would otherwise widen
|
||||||
|
* whatever holds it until the panel around it gives way. This keeps the width
|
||||||
|
* the layout asked for and moves the text instead — and stays perfectly still
|
||||||
|
* when it already fits, so a column of these is not a column of motion.
|
||||||
|
*
|
||||||
|
* Reduced motion is handled by the `MotionConfig reducedMotion="user"` each app
|
||||||
|
* root is wrapped in: the text simply sits at its start.
|
||||||
|
*/
|
||||||
|
export function Marquee({
|
||||||
|
text,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
text: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null)
|
||||||
|
const [overflow, setOverflow] = useState(0)
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: a new string is what changes the measurement.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current
|
||||||
|
if (!el) return
|
||||||
|
const measure = () =>
|
||||||
|
setOverflow(Math.max(0, el.scrollWidth - el.clientWidth))
|
||||||
|
measure()
|
||||||
|
// The panel it sits in can be resized, and the same text overflows or does
|
||||||
|
// not depending on how much room it was given.
|
||||||
|
const observer = new ResizeObserver(measure)
|
||||||
|
observer.observe(el)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [text])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
className={cn("min-w-0 overflow-hidden whitespace-nowrap", className)}
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
className="inline-block"
|
||||||
|
animate={{ x: -overflow }}
|
||||||
|
transition={
|
||||||
|
overflow
|
||||||
|
? {
|
||||||
|
duration: overflow / SPEED,
|
||||||
|
ease: "linear",
|
||||||
|
delay: DWELL,
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
repeatType: "reverse",
|
||||||
|
repeatDelay: DWELL,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</motion.span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
import { useMutation } from "@tanstack/react-query"
|
import { useMutation } from "@tanstack/react-query"
|
||||||
import { ArrowRight, RotateCcw, Trash2 } from "lucide-react"
|
import { ArrowRight, RotateCcw, Trash2 } from "lucide-react"
|
||||||
import { motion } from "motion/react"
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
|
||||||
|
|
||||||
import { FlowsService } from "@/client"
|
import { FlowsService } from "@/client"
|
||||||
|
import { Marquee } from "@/components/Common/Marquee"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
|
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
|
||||||
import useCustomToast from "@/hooks/useCustomToast"
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { displayName } from "./deriveEdges"
|
import { displayName } from "./deriveEdges"
|
||||||
import { useLiveValue } from "./liveStore"
|
import { useLiveValue } from "./liveStore"
|
||||||
import { MessageSparkline } from "./MessageSparkline"
|
import { MessageSparkline } from "./MessageSparkline"
|
||||||
|
import { ValuePreview } from "./ValuePreview"
|
||||||
|
|
||||||
function relativeTime(ts: number | null | undefined): string {
|
function relativeTime(ts: number | null | undefined): string {
|
||||||
if (!ts) return "not seen yet"
|
if (!ts) return "not seen yet"
|
||||||
@@ -35,47 +33,6 @@ function formatScalar(value: unknown): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Text that scrolls its own overflow into view and back, so a long name stays
|
|
||||||
* readable without widening the popover. Still text at rest when it fits.
|
|
||||||
*/
|
|
||||||
function Marquee({ text, className }: { text: string; className?: string }) {
|
|
||||||
const ref = useRef<HTMLSpanElement>(null)
|
|
||||||
const [overflow, setOverflow] = useState(0)
|
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: a new string is what changes the measurement.
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current
|
|
||||||
if (el) setOverflow(Math.max(0, el.scrollWidth - el.clientWidth))
|
|
||||||
}, [text])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
ref={ref}
|
|
||||||
className={cn("min-w-0 overflow-hidden whitespace-nowrap", className)}
|
|
||||||
>
|
|
||||||
<motion.span
|
|
||||||
className="inline-block"
|
|
||||||
animate={{ x: -overflow }}
|
|
||||||
transition={
|
|
||||||
overflow
|
|
||||||
? {
|
|
||||||
duration: overflow / 25,
|
|
||||||
ease: "linear",
|
|
||||||
delay: 1.2,
|
|
||||||
repeat: Number.POSITIVE_INFINITY,
|
|
||||||
repeatType: "reverse",
|
|
||||||
repeatDelay: 1.2,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</motion.span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InspectedEdge = {
|
export type InspectedEdge = {
|
||||||
message: string
|
message: string
|
||||||
/** Node titles, so the popover names the two ends in the user's own words. */
|
/** Node titles, so the popover names the two ends in the user's own words. */
|
||||||
@@ -199,11 +156,7 @@ export function EdgeInspector({
|
|||||||
Nothing has come through yet. Run the flow to see a value here.
|
Nothing has come through yet. Run the flow to see a value here.
|
||||||
</p>
|
</p>
|
||||||
) : scalar === null ? (
|
) : scalar === null ? (
|
||||||
<ScrollArea className="mt-2 max-h-48">
|
<ValuePreview value={live.value} className="mt-2" defaultOpen />
|
||||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs">
|
|
||||||
{JSON.stringify(live.value, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</ScrollArea>
|
|
||||||
) : null}
|
) : null}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
import type { HistoryPoint } from "@/client"
|
import type { DType, HistoryPoint } from "@/client"
|
||||||
import { Sparkline } from "@/components/Common/Sparkline"
|
import { Sparkline } from "@/components/Common/Sparkline"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -11,16 +11,11 @@ import {
|
|||||||
import { qualify } from "./deriveEdges"
|
import { qualify } from "./deriveEdges"
|
||||||
import { useLiveValue } from "./liveStore"
|
import { useLiveValue } from "./liveStore"
|
||||||
import { messageHistoryQueryOptions } from "./queries"
|
import { messageHistoryQueryOptions } from "./queries"
|
||||||
|
import { ValuePreview } from "./ValuePreview"
|
||||||
|
|
||||||
/** The same bound the server keeps, so the live tail cannot outgrow the window. */
|
/** The same bound the server keeps, so the live tail cannot outgrow the window. */
|
||||||
const WINDOW = 120
|
const WINDOW = 120
|
||||||
|
|
||||||
/** How a value that cannot be plotted still reads. */
|
|
||||||
function describe(value: unknown): string {
|
|
||||||
if (typeof value === "string") return value
|
|
||||||
return JSON.stringify(value) ?? String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A stretch of time in the coarsest unit that still says it. */
|
/** A stretch of time in the coarsest unit that still says it. */
|
||||||
function span(seconds: number): string {
|
function span(seconds: number): string {
|
||||||
if (seconds < 90) return `${Math.round(seconds)}s`
|
if (seconds < 90) return `${Math.round(seconds)}s`
|
||||||
@@ -65,9 +60,12 @@ function useSettled(value: string): string {
|
|||||||
export function MessageSparkline({
|
export function MessageSparkline({
|
||||||
flow,
|
flow,
|
||||||
name,
|
name,
|
||||||
|
dtype,
|
||||||
}: {
|
}: {
|
||||||
flow: string
|
flow: string
|
||||||
name: string
|
name: string
|
||||||
|
/** What the port declares, so a value that never arrived still says what it would be. */
|
||||||
|
dtype?: DType
|
||||||
}) {
|
}) {
|
||||||
const message = useSettled(name)
|
const message = useSettled(name)
|
||||||
const live = useLiveValue(qualify(flow, message))
|
const live = useLiveValue(qualify(flow, message))
|
||||||
@@ -102,21 +100,24 @@ export function MessageSparkline({
|
|||||||
].slice(-WINDOW)
|
].slice(-WINDOW)
|
||||||
|
|
||||||
if (points.length === 0) {
|
if (points.length === 0) {
|
||||||
|
// Three silences, kept apart: nothing has run, or something arrived that
|
||||||
|
// no curve can say anything about. The second is worth showing; the first
|
||||||
|
// is worth admitting to in as few words as the row can spare.
|
||||||
if (live === undefined) {
|
if (live === undefined) {
|
||||||
|
// A dash rather than a sentence: one of these sits under every port, and
|
||||||
|
// nine ports of "nothing has come through yet" is a panel of prose about
|
||||||
|
// the absence of values. The row keeps its height either way, so nothing
|
||||||
|
// shifts when the first one arrives.
|
||||||
return (
|
return (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p
|
||||||
Nothing has come through yet.
|
className="text-xs leading-5 text-muted-foreground"
|
||||||
|
title="Nothing has come through yet"
|
||||||
|
>
|
||||||
|
—
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return <ValuePreview value={live.value} dtype={dtype} />
|
||||||
<div className="flex items-baseline gap-2 text-xs">
|
|
||||||
<span className="min-w-0 flex-1 truncate font-mono">
|
|
||||||
{describe(live.value)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-muted-foreground">no history</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The value is live here, so the dot on the newest reading is earned. The
|
// The value is live here, so the dot on the newest reading is earned. The
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ function PortList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3">
|
<div className="grid min-w-0 gap-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className={SECTION}>{title}</span>
|
<span className={SECTION}>{title}</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -262,8 +262,8 @@ function PortList({
|
|||||||
{specs.map((spec, index) => (
|
{specs.map((spec, index) => (
|
||||||
// The curve reads as its own thing rather than as part of the row
|
// The curve reads as its own thing rather than as part of the row
|
||||||
// above it, so it gets a little air.
|
// above it, so it gets a little air.
|
||||||
<div key={`port-${index}`} className="grid gap-2">
|
<div key={`port-${index}`} className="grid min-w-0 gap-1.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<MessageNameInput
|
<MessageNameInput
|
||||||
value={spec.name ?? ""}
|
value={spec.name ?? ""}
|
||||||
suggestions={suggestions}
|
suggestions={suggestions}
|
||||||
@@ -279,7 +279,7 @@ function PortList({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className="!h-8 w-[92px] text-sm"
|
className="!h-8 w-[104px] text-sm"
|
||||||
aria-label="Type"
|
aria-label="Type"
|
||||||
>
|
>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -323,7 +323,7 @@ function PortList({
|
|||||||
placeholder="∞"
|
placeholder="∞"
|
||||||
aria-label="Deliver at most every n seconds"
|
aria-label="Deliver at most every n seconds"
|
||||||
title="Deliver at most every n seconds; empty is every time"
|
title="Deliver at most every n seconds; empty is every time"
|
||||||
className="h-8 w-16 text-sm"
|
className="h-8 w-14 text-sm"
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
update(index, { interval: Number(event.target.value) || 0 })
|
update(index, { interval: Number(event.target.value) || 0 })
|
||||||
}
|
}
|
||||||
@@ -357,7 +357,13 @@ function PortList({
|
|||||||
<X />
|
<X />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{spec.name ? <MessageSparkline flow={flow} name={spec.name} /> : null}
|
{spec.name ? (
|
||||||
|
<MessageSparkline
|
||||||
|
flow={flow}
|
||||||
|
name={spec.name}
|
||||||
|
dtype={spec.dtype ?? undefined}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -967,7 +973,7 @@ function PanelBody({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn("grid gap-6 p-4", expanded && "max-w-2xl")}>
|
<div className={cn("grid min-w-0 gap-6 p-4", expanded && "max-w-2xl")}>
|
||||||
<PortList
|
<PortList
|
||||||
title="Consumes"
|
title="Consumes"
|
||||||
specs={node.requires ?? []}
|
specs={node.requires ?? []}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { ChevronRight } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import type { DType } from "@/client"
|
||||||
|
import { Marquee } from "@/components/Common/Marquee"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
import { cn, si } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** How much of a structured value is worth unfolding in a side panel. */
|
||||||
|
const MAX_HEIGHT = "max-h-48"
|
||||||
|
|
||||||
|
function count(n: number, one: string, many = `${one}s`): string {
|
||||||
|
return `${n} ${n === 1 ? one : many}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this is an artifact reference rather than data of its own. */
|
||||||
|
function isArtifact(value: Record<string, unknown>): boolean {
|
||||||
|
return typeof value.digest === "string" && value.digest.startsWith("sha256:")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a structured value *is*, in the space a value would have taken.
|
||||||
|
*
|
||||||
|
* The shape is what you want while wiring — that this port carries a record of
|
||||||
|
* three fields, or a checkpoint of sixty kilobytes — and the contents are what
|
||||||
|
* you want once something looks wrong. Serialising the whole thing into a line
|
||||||
|
* answers the second question badly and the first one not at all.
|
||||||
|
*/
|
||||||
|
function summarize(value: object, dtype?: DType): string {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `${dtype ?? "list"} · ${count(value.length, "item")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
if (isArtifact(record)) {
|
||||||
|
const name =
|
||||||
|
typeof record.name === "string" && record.name ? record.name : ""
|
||||||
|
const size = typeof record.size === "number" ? `${si(record.size)}B` : ""
|
||||||
|
return ["artifact", name, size].filter(Boolean).join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(record.lines)) {
|
||||||
|
const points = record.lines.reduce(
|
||||||
|
(total: number, line: unknown) =>
|
||||||
|
total +
|
||||||
|
(Array.isArray((line as { points?: unknown[] })?.points)
|
||||||
|
? ((line as { points: unknown[] }).points.length as number)
|
||||||
|
: 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return `series · ${count(record.lines.length, "line")}, ${count(points, "point")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${dtype ?? "record"} · ${count(Object.keys(record).length, "field")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a message is carrying: its shape at rest, its contents on request.
|
||||||
|
*
|
||||||
|
* A scalar simply reads, scrolling itself when it is longer than the room it
|
||||||
|
* was given. Anything structured shows what it is and unfolds when asked —
|
||||||
|
* which is what keeps a panel four hundred pixels wide from being pushed open
|
||||||
|
* by one checkpoint reference.
|
||||||
|
*/
|
||||||
|
export function ValuePreview({
|
||||||
|
value,
|
||||||
|
dtype,
|
||||||
|
defaultOpen = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: unknown
|
||||||
|
dtype?: DType
|
||||||
|
/** Start unfolded, where there is room for it — an inspector, not a row. */
|
||||||
|
defaultOpen?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
|
const structured = value !== null && typeof value === "object"
|
||||||
|
|
||||||
|
if (!structured) {
|
||||||
|
return (
|
||||||
|
<Marquee
|
||||||
|
text={value === undefined ? "" : String(value)}
|
||||||
|
className={cn("block font-mono text-xs", className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("min-w-0", className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((was) => !was)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="flex w-full min-w-0 items-center gap-1 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
data-testid="value-preview-toggle"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"size-3 shrink-0 transition-transform duration-[--duration-fast]",
|
||||||
|
open && "rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Marquee text={summarize(value, dtype)} className="flex-1 font-mono" />
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<ScrollArea className={cn("mt-1", MAX_HEIGHT)}>
|
||||||
|
<pre className="whitespace-pre-wrap break-all font-mono text-xs">
|
||||||
|
{JSON.stringify(value, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</ScrollArea>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { api, apiPage, deleteAll } from "./utils/api"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a message carrying a dictionary looks like in a panel four hundred
|
||||||
|
* pixels wide.
|
||||||
|
*
|
||||||
|
* The regression this exists for: a record or an artifact reference was
|
||||||
|
* serialised into the row, and the panel widened until the type selects and
|
||||||
|
* the buttons beside them were pushed off the edge. The shape shows instead,
|
||||||
|
* and the contents unfold on request — so the check is both that the summary
|
||||||
|
* is what appears, and that the panel is still the width it was told to be.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const flowName = `test_values_${Date.now().toString(36)}`
|
||||||
|
/** The panel's fixed width; the whole point is that a value cannot change it. */
|
||||||
|
const PANEL_WIDTH = 400
|
||||||
|
|
||||||
|
test.use({ storageState: "playwright/.auth/user.json" })
|
||||||
|
test.describe.configure({ mode: "serial" })
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
const page = await apiPage(browser)
|
||||||
|
await api(page, `/flows/${flowName}`, {
|
||||||
|
method: "PUT",
|
||||||
|
data: {
|
||||||
|
name: flowName,
|
||||||
|
title: "Values",
|
||||||
|
version: 1,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "emit",
|
||||||
|
type: "python",
|
||||||
|
provides: [
|
||||||
|
{ name: "shape", dtype: "record" },
|
||||||
|
{ name: "label", dtype: "str" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Saving writes a draft and moves the version on, so publish what is
|
||||||
|
// actually there rather than what it was a moment ago.
|
||||||
|
const draft = await (await api(page, `/flows/${flowName}?draft=true`)).json()
|
||||||
|
await api(page, `/flows/${flowName}/publish`, {
|
||||||
|
method: "POST",
|
||||||
|
data: { version: draft.definition.version },
|
||||||
|
})
|
||||||
|
// Values a flow could plausibly hold: one structured, one longer than the
|
||||||
|
// room a 400px panel has for it.
|
||||||
|
await api(page, `/messages/${flowName}.shape`, {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
value: { title: "Recovered", body: "R2 = 0.98", severity: "info" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await api(page, `/messages/${flowName}.label`, {
|
||||||
|
method: "POST",
|
||||||
|
data: {
|
||||||
|
value: "ubuntu-gpu-node-01.lab.internal (numpy 2.5.2, cuda 12.4)",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterAll(async ({ browser }) => {
|
||||||
|
await deleteAll(browser, [`/flows/${flowName}`])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a dictionary shows its shape, and its contents on request", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(`/flows/${flowName}`)
|
||||||
|
const node = page.locator(".react-flow__node").first()
|
||||||
|
await node.waitFor({ timeout: 20000 })
|
||||||
|
await node.click()
|
||||||
|
|
||||||
|
const panel = page.locator('aside[role="complementary"]').first()
|
||||||
|
await expect(panel).toBeVisible()
|
||||||
|
|
||||||
|
// The shape, not the values.
|
||||||
|
const summary = page.getByTestId("value-preview-toggle")
|
||||||
|
await expect(summary).toContainText("record · 3 fields")
|
||||||
|
await expect(panel).not.toContainText("Recovered")
|
||||||
|
|
||||||
|
// A value cannot push the panel open, however long it is.
|
||||||
|
expect((await panel.boundingBox())?.width).toBe(PANEL_WIDTH)
|
||||||
|
|
||||||
|
// The contents are one click away.
|
||||||
|
await summary.click()
|
||||||
|
await expect(panel).toContainText("Recovered")
|
||||||
|
expect((await panel.boundingBox())?.width).toBe(PANEL_WIDTH)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user