-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(webapp): customizable runs list with columns and smart columns #4652
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ericallam
wants to merge
33
commits into
main
Choose a base branch
from
feature/tri-13328-runs-list-column-customization
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 14 commits
Commits
Show all changes
33 commits
Select commit
Hold shift + click to select a range
10b7949
feat(webapp): runs-list column registry, URL codec, and smart-column …
ericallam aaa4f28
feat(webapp): derive the runs-list Postgres select from visible columns
ericallam 6a18301
feat(webapp): column display options and smart columns on the runs list
ericallam 76c8303
feat(webapp): keep smart-column values fresh in the runs live poll
ericallam 90d3fbe
docs(webapp): add server-changes note for runs-list column customization
ericallam efe0ffd
feat(webapp): editable smart columns and display-options polish
ericallam 5b4371f
feat(webapp): stable column toggle and a clearer smart-column icon
ericallam dcb7287
feat(webapp): use a bolt icon for smart columns and reveal row action…
ericallam 918ef18
feat(webapp): compact column URL state
ericallam f86fc8d
feat(webapp): redesign the add-smart-column modal
ericallam 55586b5
feat(webapp): syntax-highlight the smart-column sample and cap large …
ericallam 8604a03
feat(webapp): click a key in the smart-column sample to fill the path
ericallam dd8d3d4
feat(webapp): only leaf values are selectable in the sample tree
ericallam f943099
feat(webapp): expand the sample tree and let it page through recent runs
ericallam 76ca32b
feat(webapp): trim smart-column sample chrome
ericallam e40a6fc
feat(webapp): thin scrollbars and inline empty objects in the sample …
ericallam 296dd0f
fix(webapp): stop the smart-column sample showing 'No runs' while loa…
ericallam af5d330
feat(webapp): sample from any run that has an inline value for the so…
ericallam 9782726
feat(webapp): default smart-column label to the array key, not the index
ericallam 7059969
feat(webapp): show a full multi-row column preview beside the sample
ericallam 14bda31
feat(webapp): support .length in smart-column JSON paths
ericallam 08734eb
polish(webapp): tighten smart-column dialog copy and display popover …
ericallam 20ca1c5
fix(webapp): address PR review on runs-list column customization
ericallam ed7c928
feat(webapp): move Display control next to pagination and add it to t…
ericallam c5756f5
feat(webapp): label the runs columns control "Columns"
ericallam 07bf4bd
fix(webapp): preserve columns on clear-filters and read escaped sampl…
ericallam dfe36b4
perf(webapp): stop hydrating run metadata on the list unless a column…
ericallam 466aff4
fix(webapp): drag-drop index, length-key sample path, live-poll paylo…
ericallam 349a36c
fix(webapp): scope smart-column sample, suppress columns in embedded …
ericallam 909c795
fix(webapp): don't coerce empty smart-column values to 0 in number/du…
ericallam df0cd8b
chore(webapp): drop unused exports flagged by knip
ericallam 4fda2b8
chore(webapp): un-export the remaining knip-flagged runColumns symbols
ericallam c2384e2
perf(webapp): sample only the selected source in the Add smart column…
ericallam File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| area: webapp | ||
| type: feature | ||
| --- | ||
|
|
||
| Customize the runs list: show, hide, and reorder columns, and add smart columns that pull a value straight out of a run's payload, metadata, or output. Your column choices are saved in the page URL so you can share or bookmark a view. |
362 changes: 362 additions & 0 deletions
362
apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,362 @@ | ||
| import { BoltIcon, ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid"; | ||
| import { useEffect, useMemo, useState } from "react"; | ||
| import { useTypedFetcher } from "remix-typedjson"; | ||
| import { Button } from "~/components/primitives/Buttons"; | ||
| import { Callout } from "~/components/primitives/Callout"; | ||
| import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; | ||
| import { Input } from "~/components/primitives/Input"; | ||
| import { Label } from "~/components/primitives/Label"; | ||
| import { Paragraph } from "~/components/primitives/Paragraph"; | ||
| import { useEnvironment } from "~/hooks/useEnvironment"; | ||
| import { useOrganization } from "~/hooks/useOrganizations"; | ||
| import { useProject } from "~/hooks/useProject"; | ||
| import { cn } from "~/utils/cn"; | ||
| import { | ||
| SMART_COLUMN_DISPLAYS, | ||
| type SmartColumnDef, | ||
| type SmartColumnDisplay, | ||
| type SmartColumnSource, | ||
| } from "./runColumns"; | ||
| import { extractSmartValue, labelFromPath, parseSource } from "./smartColumnData"; | ||
| import { SmartColumnSample } from "./SmartColumnSample"; | ||
| import type { loader as sampleLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample"; | ||
|
|
||
| type AddSmartColumnDialogProps = { | ||
| open: boolean; | ||
| /** When set, the dialog edits this existing column instead of adding a new one. */ | ||
| editing: SmartColumnDef | null; | ||
| onOpenChange: (open: boolean) => void; | ||
| onSubmit: (def: SmartColumnDef) => void; | ||
| currentSearch: string; | ||
| }; | ||
|
|
||
| const SOURCE_CARDS: { value: SmartColumnSource; label: string; description: string }[] = [ | ||
| { value: "payload", label: "Payload", description: "What you triggered the run with." }, | ||
| { value: "metadata", label: "Metadata", description: "What the run writes while it runs." }, | ||
| { value: "output", label: "Output", description: "What the run returned." }, | ||
| ]; | ||
|
|
||
| const DISPLAY_OPTIONS = SMART_COLUMN_DISPLAYS.map((display) => ({ | ||
| label: display.charAt(0).toUpperCase() + display.slice(1), | ||
| value: display, | ||
| })); | ||
|
|
||
| const DEFAULT_SOURCE: SmartColumnSource = "payload"; | ||
|
|
||
| export function AddSmartColumnDialog({ | ||
| open, | ||
| editing, | ||
| onOpenChange, | ||
| onSubmit, | ||
| currentSearch, | ||
| }: AddSmartColumnDialogProps) { | ||
| const organization = useOrganization(); | ||
| const project = useProject(); | ||
| const environment = useEnvironment(); | ||
| const sample = useTypedFetcher<typeof sampleLoader>(); | ||
|
|
||
| const [source, setSource] = useState<SmartColumnSource>(DEFAULT_SOURCE); | ||
| const [path, setPath] = useState(""); | ||
| const [label, setLabel] = useState(""); | ||
| const [labelEdited, setLabelEdited] = useState(false); | ||
| const [displayAs, setDisplayAs] = useState<SmartColumnDisplay>("text"); | ||
| const [sampleIndex, setSampleIndex] = useState(0); | ||
|
|
||
| useEffect(() => { | ||
| if (!open) return; | ||
| setSource(editing?.source ?? DEFAULT_SOURCE); | ||
| setPath(editing?.path ?? ""); | ||
| setLabel(editing?.label ?? ""); | ||
| setLabelEdited(editing !== null); | ||
| setDisplayAs(editing?.displayAs ?? "text"); | ||
| setSampleIndex(0); | ||
| }, [open, editing]); | ||
|
|
||
| const sampleUrl = useMemo(() => { | ||
| const base = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/smart-column-sample`; | ||
| return currentSearch ? `${base}?${currentSearch.replace(/^\?/, "")}` : base; | ||
| }, [organization.slug, project.slug, environment.slug, currentSearch]); | ||
|
|
||
| useEffect(() => { | ||
| if (open && sample.state === "idle" && sample.data === undefined) { | ||
| sample.load(sampleUrl); | ||
| } | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [open, sampleUrl]); | ||
|
|
||
| const effectiveLabel = labelEdited ? label : labelFromPath(path); | ||
|
|
||
| const sampleRuns = sample.data?.runs ?? []; | ||
| const clampedIndex = sampleRuns.length > 0 ? Math.min(sampleIndex, sampleRuns.length - 1) : 0; | ||
| const sampleRun = sampleRuns[clampedIndex] ?? null; | ||
|
|
||
| const parsed = useMemo(() => { | ||
| if (!sampleRun) return undefined; | ||
| switch (source) { | ||
| case "payload": | ||
| return parseSource({ data: sampleRun.payload, dataType: sampleRun.payloadType }); | ||
| case "metadata": | ||
| return parseSource({ data: sampleRun.metadata, dataType: sampleRun.metadataType }); | ||
| case "output": | ||
| return parseSource({ data: sampleRun.output, dataType: sampleRun.outputType }); | ||
| } | ||
| }, [sampleRun, source]); | ||
|
|
||
| const resolved = useMemo(() => { | ||
| if (!parsed || path.trim().length === 0) return undefined; | ||
| return extractSmartValue(parsed, path); | ||
| }, [parsed, path]); | ||
|
|
||
| const canSubmit = path.trim().length > 0; | ||
|
|
||
| const handleSubmit = () => { | ||
| if (!canSubmit) return; | ||
| onSubmit({ source, path: path.trim(), label: effectiveLabel.trim() || path.trim(), displayAs }); | ||
| onOpenChange(false); | ||
| }; | ||
|
|
||
| return ( | ||
| <Dialog open={open} onOpenChange={onOpenChange}> | ||
| <DialogContent className="sm:max-w-[820px]!"> | ||
| <DialogHeader>{editing ? "Edit smart column" : "Add smart column"}</DialogHeader> | ||
| <div className="flex flex-col gap-5 p-1"> | ||
| <Callout variant="info"> | ||
| Display only. A smart column shows you a value from a run, but you can't sort or filter | ||
| the list by it. To narrow the list, use tags or the query editor. | ||
| </Callout> | ||
|
|
||
| <div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_300px]"> | ||
| <div className="flex flex-col gap-5"> | ||
| <div className="flex flex-col gap-1.5"> | ||
| <Label>Source</Label> | ||
| <div className="grid grid-cols-3 gap-2"> | ||
| {SOURCE_CARDS.map((card) => ( | ||
| <SourceCard | ||
| key={card.value} | ||
| label={card.label} | ||
| description={card.description} | ||
| selected={source === card.value} | ||
| onSelect={() => setSource(card.value)} | ||
| /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="grid grid-cols-2 gap-4"> | ||
| <div className="flex flex-col gap-1.5"> | ||
| <Label>JSON path</Label> | ||
| <Input | ||
| value={path} | ||
| onChange={(e) => setPath(e.target.value)} | ||
| placeholder="$.order.total" | ||
| spellCheck={false} | ||
| /> | ||
| <Paragraph variant="extra-small" className="text-text-dimmed"> | ||
| Dot and bracket notation, e.g. <code>$.order.total</code> or{" "} | ||
| <code>$.items[0].sku</code>. | ||
| </Paragraph> | ||
| </div> | ||
| <div className="flex flex-col gap-1.5"> | ||
| <Label>Column label</Label> | ||
| <Input | ||
| value={effectiveLabel} | ||
| onChange={(e) => { | ||
| setLabel(e.target.value); | ||
| setLabelEdited(true); | ||
| }} | ||
| placeholder={labelFromPath(path)} | ||
| /> | ||
| <Paragraph variant="extra-small" className="text-text-dimmed"> | ||
| Defaults to the last part of the path. | ||
| </Paragraph> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="flex flex-col gap-1.5"> | ||
| <Label>Display as</Label> | ||
| <div className="flex flex-wrap gap-2"> | ||
| {DISPLAY_OPTIONS.map((option) => ( | ||
| <button | ||
| key={option.value} | ||
| type="button" | ||
| onClick={() => setDisplayAs(option.value)} | ||
| className={cn( | ||
| "rounded-full border px-3.5 py-1 text-sm transition", | ||
| displayAs === option.value | ||
| ? "border-blue-500 bg-blue-500/10 text-text-bright" | ||
| : "border-grid-bright text-text-dimmed hover:text-text-bright" | ||
| )} | ||
| > | ||
| {option.label} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| <Paragraph variant="extra-small" className="text-text-dimmed"> | ||
| Number right-aligns the column and uses tabular figures. Anything that doesn't | ||
| parse falls back to text. | ||
| </Paragraph> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="flex flex-col gap-1.5 self-start rounded-lg border border-grid-dimmed bg-background-dimmed p-3"> | ||
| <div className="flex items-center justify-between gap-2"> | ||
| <Paragraph variant="extra-extra-small/dimmed/caps">Sample — {source}</Paragraph> | ||
| {sampleRuns.length > 0 && ( | ||
| <SampleRunPicker | ||
| index={clampedIndex} | ||
| total={sampleRuns.length} | ||
| onPrev={() => setSampleIndex((i) => Math.max(0, i - 1))} | ||
| onNext={() => setSampleIndex((i) => Math.min(sampleRuns.length - 1, i + 1))} | ||
| /> | ||
| )} | ||
| </div> | ||
| {sample.state === "loading" ? ( | ||
| <Paragraph variant="extra-small" className="text-text-dimmed"> | ||
| Loading… | ||
| </Paragraph> | ||
| ) : !parsed ? ( | ||
| <Paragraph variant="extra-small" className="text-text-dimmed"> | ||
| No runs to sample. | ||
| </Paragraph> | ||
| ) : parsed.state === "offloaded" ? ( | ||
| <Paragraph variant="extra-small" className="text-text-dimmed"> | ||
| This {source} is offloaded to object storage, too large to sample here. | ||
| </Paragraph> | ||
| ) : parsed.state === "empty" ? ( | ||
| <Paragraph variant="extra-small" className="text-text-dimmed"> | ||
| No {source} value for this run. | ||
| </Paragraph> | ||
| ) : ( | ||
| <> | ||
| <SmartColumnSample | ||
| value={parsed.value} | ||
| activePath={path.trim()} | ||
| onSelectPath={setPath} | ||
| /> | ||
| <Paragraph variant="extra-small" className="text-text-dimmed"> | ||
| Click a value to use its path. Expand objects and arrays to reach the value you | ||
| want. | ||
| </Paragraph> | ||
| </> | ||
| )} | ||
| <Paragraph variant="extra-extra-small/dimmed/caps" className="mt-2"> | ||
| Resolves to | ||
| </Paragraph> | ||
| <SmartColumnResolvedPreview label={effectiveLabel} resolved={resolved} /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <div className="flex items-center justify-end gap-2 border-t border-grid-dimmed p-3"> | ||
| <Button variant="tertiary/medium" onClick={() => onOpenChange(false)}> | ||
| Cancel | ||
| </Button> | ||
| <Button variant="primary/medium" disabled={!canSubmit} onClick={handleSubmit}> | ||
| {editing ? "Save changes" : "Add column"} | ||
| </Button> | ||
| </div> | ||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| } | ||
|
|
||
| function SampleRunPicker({ | ||
| index, | ||
| total, | ||
| onPrev, | ||
| onNext, | ||
| }: { | ||
| index: number; | ||
| total: number; | ||
| onPrev: () => void; | ||
| onNext: () => void; | ||
| }) { | ||
| return ( | ||
| <div className="flex flex-none items-center gap-1 text-xs text-text-dimmed"> | ||
| <span className="tabular-nums"> | ||
| {index + 1}/{total} | ||
| </span> | ||
| <button | ||
| type="button" | ||
| onClick={onPrev} | ||
| disabled={index === 0} | ||
| aria-label="Newer run" | ||
| className="flex size-5 items-center justify-center rounded hover:bg-charcoal-750 disabled:opacity-30" | ||
| > | ||
| <ChevronLeftIcon className="size-4" /> | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={onNext} | ||
| disabled={index >= total - 1} | ||
| aria-label="Older run" | ||
| className="flex size-5 items-center justify-center rounded hover:bg-charcoal-750 disabled:opacity-30" | ||
| > | ||
| <ChevronRightIcon className="size-4" /> | ||
| </button> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function SourceCard({ | ||
| label, | ||
| description, | ||
| selected, | ||
| onSelect, | ||
| }: { | ||
| label: string; | ||
| description: string; | ||
| selected: boolean; | ||
| onSelect: () => void; | ||
| }) { | ||
| return ( | ||
| <button | ||
| type="button" | ||
| onClick={onSelect} | ||
| aria-pressed={selected} | ||
| className={cn( | ||
| "flex flex-col gap-1 rounded-lg border p-2.5 text-left transition", | ||
| selected | ||
| ? "border-blue-500 bg-blue-500/10" | ||
| : "border-grid-bright bg-background-dimmed hover:border-text-dimmed" | ||
| )} | ||
| > | ||
| <span className="flex items-center gap-1.5 text-sm font-medium text-text-bright"> | ||
| <span | ||
| className={cn( | ||
| "grid size-3.5 flex-none place-items-center rounded-full border", | ||
| selected ? "border-blue-500" : "border-text-dimmed" | ||
| )} | ||
| > | ||
| {selected && <span className="size-1.5 rounded-full bg-blue-500" />} | ||
| </span> | ||
| {label} | ||
| </span> | ||
| <span className="text-xs text-text-dimmed">{description}</span> | ||
| </button> | ||
| ); | ||
| } | ||
|
|
||
| function SmartColumnResolvedPreview({ | ||
| label, | ||
| resolved, | ||
| }: { | ||
| label: string; | ||
| resolved: ReturnType<typeof extractSmartValue> | undefined; | ||
| }) { | ||
| let value: string; | ||
| if (!resolved) value = "–"; | ||
| else if (resolved.state === "offloaded") value = "Too large"; | ||
| else if (resolved.state === "empty") value = "–"; | ||
| else if (typeof resolved.value === "object") value = JSON.stringify(resolved.value); | ||
| else value = String(resolved.value); | ||
|
|
||
| return ( | ||
| <div className="rounded border border-grid-dimmed"> | ||
| <div className="flex items-center gap-1 border-b border-grid-dimmed px-2 py-1"> | ||
| <BoltIcon className="size-3.5 flex-none text-text-dimmed" /> | ||
| <span className="truncate text-xs text-text-bright">{label || "Column"}</span> | ||
| </div> | ||
| <div className="px-2 py-1.5 text-right text-sm tabular-nums text-text-bright">{value}</div> | ||
| </div> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Sample preview can get stuck on the wrong data when switching source quickly
The request for fresh sample data is skipped (
sample.state === "idle"guard atapps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx:102) whenever a previous request is still in flight, and it is never retried, so the preview can keep showing the old source's data indefinitely.Impact: If someone flips between Payload/Metadata/Output while the previous sample is still loading, the sample and preview panes show nothing (or the wrong source) until they click a source again.
Effect dependency array cannot re-fire after the in-flight fetch settles
The effect's dependencies are
[open, sampleUrl]. When the user changessource,sampleUrlchanges and the effect runs, but ifsample.stateis"loading"at that moment theload()call is skipped. When the earlier request finishes and the fetcher returns to"idle", the dependencies have not changed, so the effect never re-runs and the newsampleUrlis never requested.Because the sample loader only hydrates the requested source (
runSelect: deriveRunSelect([], [source])atapps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample.ts:57), the retained data contains only the previous source's blob.perRun(apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx:125-145) then parsesrun.output/run.metadataasundefined, so both the sample tree and the preview column render the "no source to sample" empty state.The guard is also unnecessary for preventing duplicate loads: the effect already only fires when
openorsampleUrlchange.Was this helpful? React with 👍 or 👎 to provide feedback.