Skip to content

Commit 6a18301

Browse files
committed
feat(webapp): column display options and smart columns on the runs list
Adds a Display popover to show/hide and reorder columns, and lets you add "smart columns" that pull a JSON value out of a run's payload, metadata, or output. Column choices live in the URL. ID, Task, and Status can be reordered but not hidden. Smart columns are display-only; offloaded or missing values render a clear placeholder.
1 parent aaa4f28 commit 6a18301

5 files changed

Lines changed: 1150 additions & 299 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { useEffect, useMemo, useState } from "react";
2+
import { useTypedFetcher } from "remix-typedjson";
3+
import { Button } from "~/components/primitives/Buttons";
4+
import { Callout } from "~/components/primitives/Callout";
5+
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
6+
import { Input } from "~/components/primitives/Input";
7+
import { Label } from "~/components/primitives/Label";
8+
import { Paragraph } from "~/components/primitives/Paragraph";
9+
import SegmentedControl from "~/components/primitives/SegmentedControl";
10+
import { Switch } from "~/components/primitives/Switch";
11+
import { useEnvironment } from "~/hooks/useEnvironment";
12+
import { useOrganization } from "~/hooks/useOrganizations";
13+
import { useProject } from "~/hooks/useProject";
14+
import { cn } from "~/utils/cn";
15+
import {
16+
SMART_COLUMN_DISPLAYS,
17+
SMART_COLUMN_SOURCES,
18+
type SmartColumnDef,
19+
type SmartColumnDisplay,
20+
type SmartColumnSource,
21+
} from "./runColumns";
22+
import {
23+
extractSmartValue,
24+
labelFromPath,
25+
parseSource,
26+
SMART_SOURCE_DOT_COLOR,
27+
} from "./smartColumnData";
28+
import type { loader as sampleLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample";
29+
30+
type AddSmartColumnDialogProps = {
31+
open: boolean;
32+
onOpenChange: (open: boolean) => void;
33+
onAdd: (def: SmartColumnDef) => void;
34+
currentSearch: string;
35+
};
36+
37+
const SOURCE_OPTIONS = SMART_COLUMN_SOURCES.map((source) => ({
38+
label: source.charAt(0).toUpperCase() + source.slice(1),
39+
value: source,
40+
}));
41+
42+
const DISPLAY_OPTIONS = SMART_COLUMN_DISPLAYS.map((display) => ({
43+
label: display.charAt(0).toUpperCase() + display.slice(1),
44+
value: display,
45+
}));
46+
47+
export function AddSmartColumnDialog({
48+
open,
49+
onOpenChange,
50+
onAdd,
51+
currentSearch,
52+
}: AddSmartColumnDialogProps) {
53+
const organization = useOrganization();
54+
const project = useProject();
55+
const environment = useEnvironment();
56+
const sample = useTypedFetcher<typeof sampleLoader>();
57+
58+
const [source, setSource] = useState<SmartColumnSource>("metadata");
59+
const [path, setPath] = useState("");
60+
const [label, setLabel] = useState("");
61+
const [labelEdited, setLabelEdited] = useState(false);
62+
const [displayAs, setDisplayAs] = useState<SmartColumnDisplay>("text");
63+
64+
const sampleUrl = useMemo(() => {
65+
const base = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/smart-column-sample`;
66+
return currentSearch ? `${base}?${currentSearch.replace(/^\?/, "")}` : base;
67+
}, [organization.slug, project.slug, environment.slug, currentSearch]);
68+
69+
useEffect(() => {
70+
if (open && sample.state === "idle" && sample.data === undefined) {
71+
sample.load(sampleUrl);
72+
}
73+
// eslint-disable-next-line react-hooks/exhaustive-deps
74+
}, [open, sampleUrl]);
75+
76+
const effectiveLabel = labelEdited ? label : labelFromPath(path);
77+
78+
const sampleRun = sample.data?.run ?? null;
79+
80+
const parsed = useMemo(() => {
81+
if (!sampleRun) return undefined;
82+
switch (source) {
83+
case "payload":
84+
return parseSource({ data: sampleRun.payload, dataType: sampleRun.payloadType });
85+
case "metadata":
86+
return parseSource({ data: sampleRun.metadata, dataType: sampleRun.metadataType });
87+
case "output":
88+
return parseSource({ data: sampleRun.output, dataType: sampleRun.outputType });
89+
}
90+
}, [sampleRun, source]);
91+
92+
const sampleJson = useMemo(() => {
93+
if (!parsed) return undefined;
94+
if (parsed.state === "offloaded") return "// offloaded to object storage";
95+
if (parsed.state === "empty") return "// no value for this run";
96+
try {
97+
return JSON.stringify(parsed.value, null, 2);
98+
} catch {
99+
return String(parsed.value);
100+
}
101+
}, [parsed]);
102+
103+
const resolved = useMemo(() => {
104+
if (!parsed || path.trim().length === 0) return undefined;
105+
return extractSmartValue(parsed, path);
106+
}, [parsed, path]);
107+
108+
const canAdd = path.trim().length > 0;
109+
110+
const reset = () => {
111+
setSource("metadata");
112+
setPath("");
113+
setLabel("");
114+
setLabelEdited(false);
115+
setDisplayAs("text");
116+
};
117+
118+
const handleAdd = () => {
119+
if (!canAdd) return;
120+
onAdd({ source, path: path.trim(), label: effectiveLabel.trim() || path.trim(), displayAs });
121+
reset();
122+
onOpenChange(false);
123+
};
124+
125+
return (
126+
<Dialog
127+
open={open}
128+
onOpenChange={(next) => {
129+
if (!next) reset();
130+
onOpenChange(next);
131+
}}
132+
>
133+
<DialogContent className="max-w-2xl">
134+
<DialogHeader>Add smart column</DialogHeader>
135+
<div className="flex flex-col gap-4 p-1">
136+
<div className="flex flex-col gap-1.5">
137+
<Label>Source</Label>
138+
<SegmentedControl
139+
name="smart-column-source"
140+
value={source}
141+
options={SOURCE_OPTIONS}
142+
onChange={(value: string) => setSource(value as SmartColumnSource)}
143+
fullWidth
144+
/>
145+
<Paragraph variant="extra-small" className="text-text-dimmed">
146+
Metadata is what the run writes about itself while it runs, so it has a value before
147+
the run ends. Payload is what you triggered it with; output is what it returned.
148+
</Paragraph>
149+
</div>
150+
151+
<div className="grid grid-cols-2 gap-4">
152+
<div className="flex flex-col gap-1.5">
153+
<Label>JSON path</Label>
154+
<Input
155+
value={path}
156+
onChange={(e) => setPath(e.target.value)}
157+
placeholder="$.failed"
158+
spellCheck={false}
159+
/>
160+
<Paragraph variant="extra-small" className="text-text-dimmed">
161+
Dot and bracket notation, e.g. <code>$.failed</code> or{" "}
162+
<code>$.suites[0].name</code>.
163+
</Paragraph>
164+
</div>
165+
<div className="flex flex-col gap-1.5">
166+
<Label>Column label</Label>
167+
<Input
168+
value={effectiveLabel}
169+
onChange={(e) => {
170+
setLabel(e.target.value);
171+
setLabelEdited(true);
172+
}}
173+
placeholder={labelFromPath(path)}
174+
/>
175+
<Paragraph variant="extra-small" className="text-text-dimmed">
176+
Defaults to the last part of the path. Rename it to anything you like.
177+
</Paragraph>
178+
</div>
179+
</div>
180+
181+
<div className="flex flex-col gap-1.5">
182+
<Label>Display as</Label>
183+
<SegmentedControl
184+
name="smart-column-display"
185+
value={displayAs}
186+
options={DISPLAY_OPTIONS}
187+
onChange={(value: string) => setDisplayAs(value as SmartColumnDisplay)}
188+
fullWidth
189+
/>
190+
<Paragraph variant="extra-small" className="text-text-dimmed">
191+
Number right-aligns the column and uses tabular figures. Anything that doesn't parse
192+
falls back to text.
193+
</Paragraph>
194+
</div>
195+
196+
<div className="grid grid-cols-2 gap-4 rounded border border-grid-dimmed p-3">
197+
<div className="flex min-w-0 flex-col gap-1.5">
198+
<Paragraph variant="extra-extra-small/dimmed/caps">
199+
Sample — {source} of the newest run
200+
</Paragraph>
201+
<pre className="max-h-40 overflow-auto rounded bg-background-dimmed p-2 text-xs text-text-dimmed">
202+
{sample.state === "loading"
203+
? "Loading…"
204+
: sampleRun
205+
? sampleJson
206+
: "// no runs to sample"}
207+
</pre>
208+
</div>
209+
<div className="flex flex-col gap-1.5">
210+
<Paragraph variant="extra-extra-small/dimmed/caps">Resolves to</Paragraph>
211+
<SmartColumnResolvedPreview
212+
source={source}
213+
label={effectiveLabel}
214+
resolved={resolved}
215+
/>
216+
{sampleRun && (
217+
<Paragraph variant="extra-small" className="text-text-dimmed">
218+
Against {sampleRun.friendlyId}
219+
{sampleRun.hasFinished ? "" : " · still running"}
220+
</Paragraph>
221+
)}
222+
</div>
223+
</div>
224+
225+
<div className="flex items-center gap-6 rounded border border-grid-dimmed px-3 py-2 opacity-60">
226+
<Switch variant="small" label="Sort by this column" disabled checked={false} />
227+
<Switch variant="small" label="Add to filters" disabled checked={false} />
228+
<Paragraph variant="extra-small" className="ml-auto text-text-dimmed">
229+
Both off, and not switchable
230+
</Paragraph>
231+
</div>
232+
233+
<Callout variant="warning">
234+
Display only. A smart column shows you a value, but you can't sort or filter the list by
235+
it. To narrow the list, use tags or the query editor.
236+
</Callout>
237+
</div>
238+
<div className="flex items-center justify-end gap-2 border-t border-grid-dimmed p-3">
239+
<Button variant="tertiary/medium" onClick={() => onOpenChange(false)}>
240+
Cancel
241+
</Button>
242+
<Button variant="primary/medium" disabled={!canAdd} onClick={handleAdd}>
243+
Add column
244+
</Button>
245+
</div>
246+
</DialogContent>
247+
</Dialog>
248+
);
249+
}
250+
251+
function SmartColumnResolvedPreview({
252+
source,
253+
label,
254+
resolved,
255+
}: {
256+
source: SmartColumnSource;
257+
label: string;
258+
resolved: ReturnType<typeof extractSmartValue> | undefined;
259+
}) {
260+
let value: string;
261+
if (!resolved) value = "–";
262+
else if (resolved.state === "offloaded") value = "Too large";
263+
else if (resolved.state === "empty") value = "–";
264+
else if (typeof resolved.value === "object") value = JSON.stringify(resolved.value);
265+
else value = String(resolved.value);
266+
267+
return (
268+
<div className="rounded border border-grid-dimmed">
269+
<div className="flex items-center gap-1.5 border-b border-grid-dimmed px-2 py-1">
270+
<span className={cn("size-2 rounded-full", SMART_SOURCE_DOT_COLOR[source])} />
271+
<span className="truncate text-xs text-text-bright">{label || "Column"}</span>
272+
</div>
273+
<div className="px-2 py-1.5 text-right text-sm tabular-nums text-text-bright">{value}</div>
274+
</div>
275+
);
276+
}

apps/webapp/app/components/runs/v3/RunFilters.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import { type loader as versionsLoader } from "~/routes/resources.orgs.$organiza
6868
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
6969
import { Button } from "../../primitives/Buttons";
7070
import { AIFilterInput } from "./AIFilterInput";
71+
import { RunsDisplayOptions } from "./RunsDisplayOptions";
7172
import { BulkActionTypeCombo } from "./BulkAction";
7273
import { RegionLabel } from "./RegionLabel";
7374
import {
@@ -415,6 +416,7 @@ export function RunsFilters(props: RunFiltersProps) {
415416
/>
416417
</Form>
417418
)}
419+
<RunsDisplayOptions />
418420
</div>
419421
);
420422
}

0 commit comments

Comments
 (0)