Skip to content

Commit 918ef18

Browse files
committed
feat(webapp): compact column URL state
Column state is now delta-encoded: order is written only when it differs from the default, and hidden columns are a single `hide` list. Removing one column produces `?hide=ver` instead of the whole ordered list.
1 parent dcb7287 commit 918ef18

6 files changed

Lines changed: 145 additions & 78 deletions

File tree

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { useSearchParams } from "~/hooks/useSearchParam";
1818
import { cn } from "~/utils/cn";
1919
import {
2020
encodeColumnLayout,
21+
parseColumnParams,
2122
resolveColumnLayout,
2223
type LayoutColumn,
2324
type ResolvedColumn,
@@ -36,7 +37,7 @@ export function RunsDisplayOptions() {
3637
const environment = useEnvironment();
3738
const { isManagedCloud } = useFeatures();
3839
const location = useOptimisticLocation();
39-
const { values, replace } = useSearchParams();
40+
const { value, values, replace } = useSearchParams();
4041
const [addOpen, setAddOpen] = useState(false);
4142
const [editing, setEditing] = useState<SmartEditTarget | null>(null);
4243
const [dragKey, setDragKey] = useState<string | null>(null);
@@ -47,12 +48,13 @@ export function RunsDisplayOptions() {
4748
isDevelopment: environment.type === "DEVELOPMENT",
4849
};
4950

50-
const cols = values("cols");
51+
const colsParam = value("cols");
52+
const hideParam = value("hide");
5153
const sc = values("sc");
5254
const layout = useMemo(
53-
() => resolveColumnLayout({ cols, sc }, runtime),
55+
() => resolveColumnLayout(parseColumnParams(colsParam, sc, hideParam), runtime),
5456
// eslint-disable-next-line react-hooks/exhaustive-deps
55-
[cols.join(" "), sc.join(" "), runtime.isManagedCloud, runtime.isDevelopment]
57+
[colsParam, hideParam, sc.join(" "), runtime.isManagedCloud, runtime.isDevelopment]
5658
);
5759

5860
const totalCount = layout.ordered.filter((o) => o.col.kind === "standard").length;
@@ -61,11 +63,14 @@ export function RunsDisplayOptions() {
6163
const applyLayout = (next: LayoutColumn[]) => {
6264
const encoded = encodeColumnLayout(next, runtime);
6365
replace({
64-
cols: encoded.cols.length > 0 ? encoded.cols : undefined,
66+
cols: encoded.cols.length > 0 ? encoded.cols.join(",") : undefined,
6567
sc: encoded.sc.length > 0 ? encoded.sc : undefined,
68+
hide: encoded.hide.length > 0 ? encoded.hide.join(",") : undefined,
6669
});
6770
};
6871

72+
const reset = () => replace({ cols: undefined, sc: undefined, hide: undefined });
73+
6974
const toggleHidden = (key: string) => {
7075
applyLayout(
7176
layout.ordered.map((o) => (keyFor(o.col) === key ? { ...o, hidden: !o.hidden } : o))
@@ -93,8 +98,6 @@ export function RunsDisplayOptions() {
9398
}
9499
};
95100

96-
const reset = () => replace({ cols: undefined, sc: undefined });
97-
98101
const reorder = (fromKey: string, toKey: string) => {
99102
if (fromKey === toKey) return;
100103
const arr = [...layout.ordered];

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { useSearchParams } from "~/hooks/useSearchParam";
6666
import type { TaskTriggerSource } from "@trigger.dev/database";
6767
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
6868
import {
69+
parseColumnParams,
6970
resolveColumnLayout,
7071
visibleSmartSources,
7172
type ResolvedColumn,
@@ -655,15 +656,15 @@ export function TaskRunsTable({
655656
const tableStateParam = disableAdjacentRows ? "" : encodeURIComponent(search);
656657

657658
const isDevelopment = environment.type === "DEVELOPMENT";
658-
const colsFromUrl = values("cols");
659+
const colsParam = value("cols");
660+
const hideParam = value("hide");
659661
const scFromUrl = values("sc");
660-
const colsKey = colsFromUrl.join(" ");
661662
const scKey = scFromUrl.join(" ");
662663
const layout = useMemo(() => {
663664
const runtime: RunColumnRuntime = { isManagedCloud, isDevelopment };
664-
return resolveColumnLayout({ cols: colsFromUrl, sc: scFromUrl }, runtime);
665+
return resolveColumnLayout(parseColumnParams(colsParam, scFromUrl, hideParam), runtime);
665666
// eslint-disable-next-line react-hooks/exhaustive-deps
666-
}, [colsKey, scKey, isManagedCloud, isDevelopment]);
667+
}, [colsParam, hideParam, scKey, isManagedCloud, isDevelopment]);
667668

668669
const visibleColumns = layout.visible;
669670
const referencedSources = useMemo(() => visibleSmartSources(visibleColumns), [visibleColumns]);

apps/webapp/app/components/runs/v3/runColumns.test.ts

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -88,47 +88,48 @@ const orderedIds = (layout: { ordered: { col: ResolvedColumn }[] }) =>
8888
const visibleIds = (layout: { visible: ResolvedColumn[] }) =>
8989
layout.visible.map((c) => (c.kind === "standard" ? c.def.id : c.def.label));
9090

91+
const params = (over: Partial<{ cols: string[]; sc: string[]; hide: string[] }> = {}) => ({
92+
cols: [],
93+
sc: [],
94+
hide: [],
95+
...over,
96+
});
97+
9198
describe("resolveColumnLayout", () => {
92-
it("returns the default layout when cols is absent", () => {
93-
const layout = resolveColumnLayout({ cols: [], sc: [] }, cloud);
99+
it("returns the default layout when no params are set", () => {
100+
const layout = resolveColumnLayout(params(), cloud);
94101
expect(layout.isCustomized).toBe(false);
95102
expect(layout.ordered.every((o) => !o.hidden)).toBe(true);
96103
expect(layout.ordered[0].col).toMatchObject({ kind: "standard", def: { id: "id" } });
97104
expect(layout.visible).toHaveLength(availableStandardColumns(cloud).length);
98105
});
99106

100107
it("keeps every column in the requested order (columns are reorderable)", () => {
101-
const layout = resolveColumnLayout({ cols: ["task", "status", "id"], sc: [] }, cloud);
108+
const layout = resolveColumnLayout(params({ cols: ["task", "status", "id"] }), cloud);
102109
expect(orderedIds(layout).slice(0, 3)).toEqual(["task", "status", "id"]);
103110
});
104111

105-
it("hides a `-`-prefixed column in place without dropping it from the order", () => {
106-
const layout = resolveColumnLayout(
107-
{ cols: ["id", "task", "status", "ver", "-ttl", "tags"], sc: [] },
108-
cloud
109-
);
112+
it("hides columns from the `hide` list in place, keeping the default order", () => {
113+
const layout = resolveColumnLayout(params({ hide: ["ttl"] }), cloud);
110114
const ttl = layout.ordered.find((o) => o.col.kind === "standard" && o.col.def.id === "ttl");
111115
expect(ttl?.hidden).toBe(true);
112116
const ids = orderedIds(layout);
113-
expect(ids.indexOf("ttl")).toBeGreaterThan(ids.indexOf("ver"));
114117
expect(ids.indexOf("ttl")).toBeLessThan(ids.indexOf("tags"));
115118
expect(visibleIds(layout)).not.toContain("ttl");
116119
});
117120

118-
it("never hides locked columns, even with a `-` prefix", () => {
119-
const layout = resolveColumnLayout({ cols: ["id", "-task", "-status", "ver"], sc: [] }, cloud);
120-
const locked = layout.ordered.filter(
121-
(o) => o.col.kind === "standard" && o.col.def.locked
122-
);
121+
it("never hides locked columns even if the `hide` list names them", () => {
122+
const layout = resolveColumnLayout(params({ hide: ["task", "status"] }), cloud);
123+
const locked = layout.ordered.filter((o) => o.col.kind === "standard" && o.col.def.locked);
123124
expect(locked.every((o) => !o.hidden)).toBe(true);
124125
});
125126

126127
it("reinserts standard columns missing from the URL as visible", () => {
127-
const layout = resolveColumnLayout({ cols: ["id", "ver"], sc: [] }, cloud);
128+
const layout = resolveColumnLayout(params({ cols: ["id", "ver"] }), cloud);
128129
expect(visibleIds(layout)).toEqual(expect.arrayContaining(["task", "status", "tags", "ttl"]));
129130
});
130131

131-
it("resolves smart-column refs positionally", () => {
132+
it("resolves smart-column refs positionally, even without a cols order", () => {
132133
const sc = [
133134
encodeSmartColumn({
134135
source: "metadata",
@@ -137,28 +138,52 @@ describe("resolveColumnLayout", () => {
137138
displayAs: "number",
138139
}),
139140
];
140-
const layout = resolveColumnLayout({ cols: ["id", "sc1"], sc }, cloud);
141+
const layout = resolveColumnLayout(params({ sc }), cloud);
141142
const smart = layout.visible.find((c) => c.kind === "smart");
142143
expect(smart).toMatchObject({ kind: "smart", def: { label: "Failed", source: "metadata" } });
143144
});
144145

145146
it("drops gated columns referenced on a runtime that lacks them", () => {
146-
const layout = resolveColumnLayout({ cols: ["id", "region", "compute", "task"], sc: [] }, dev);
147+
const layout = resolveColumnLayout(params({ cols: ["id", "region", "compute", "task"] }), dev);
147148
expect(orderedIds(layout)).not.toContain("region");
148149
expect(orderedIds(layout)).not.toContain("compute");
149150
expect(orderedIds(layout).slice(0, 2)).toEqual(["id", "task"]);
150151
});
151152
});
152153

153-
describe("encodeColumnLayout round-trip", () => {
154+
describe("encodeColumnLayout compactness + round-trip", () => {
154155
const std = (id: string) => ({
155156
kind: "standard" as const,
156157
def: availableStandardColumns(cloud).find((c) => c.id === id)!,
157158
});
158159

159160
it("encodes the default layout to empty params", () => {
160-
const layout = resolveColumnLayout({ cols: [], sc: [] }, cloud);
161-
expect(encodeColumnLayout(layout.ordered, cloud)).toEqual({ cols: [], sc: [] });
161+
const layout = resolveColumnLayout(params(), cloud);
162+
expect(encodeColumnLayout(layout.ordered, cloud)).toEqual({ cols: [], sc: [], hide: [] });
163+
});
164+
165+
it("hiding a column with the default order emits only a hide entry, no cols", () => {
166+
const layout = resolveColumnLayout(params({ hide: ["ver"] }), cloud);
167+
const encoded = encodeColumnLayout(layout.ordered, cloud);
168+
expect(encoded.cols).toEqual([]);
169+
expect(encoded.hide).toEqual(["ver"]);
170+
expect(encoded.sc).toEqual([]);
171+
});
172+
173+
it("appending a smart column with the default order emits only sc, no cols", () => {
174+
const scDef: SmartColumnDef = {
175+
source: "metadata",
176+
path: "$.failed",
177+
label: "Failed",
178+
displayAs: "number",
179+
};
180+
const layout = resolveColumnLayout(params(), cloud);
181+
const encoded = encodeColumnLayout(
182+
[...layout.ordered, { col: { kind: "smart", index: 0, def: scDef }, hidden: false }],
183+
cloud
184+
);
185+
expect(encoded.cols).toEqual([]);
186+
expect(encoded.sc).toHaveLength(1);
162187
});
163188

164189
it("round-trips a reordered, hidden, smart-augmented layout", () => {
@@ -177,7 +202,8 @@ describe("encodeColumnLayout round-trip", () => {
177202
],
178203
cloud
179204
);
180-
expect(encoded.cols).toEqual(["id", "status", "-ttl", "sc1"]);
205+
expect(encoded.cols).toEqual(["id", "status", "ttl", "sc1"]);
206+
expect(encoded.hide).toEqual(["ttl"]);
181207
expect(encoded.sc).toHaveLength(1);
182208

183209
const layout = resolveColumnLayout(encoded, cloud);

apps/webapp/app/components/runs/v3/runColumns.ts

Lines changed: 72 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -268,58 +268,77 @@ export type ColumnLayout = {
268268
isCustomized: boolean;
269269
};
270270

271-
/** A hidden column is written into `cols` with this prefix, keeping its slot. */
272-
const HIDDEN_PREFIX = "-";
271+
export type ColumnLayoutParams = { cols: string[]; sc: string[]; hide: string[] };
272+
export type EncodedColumnLayout = { cols: string[]; sc: string[]; hide: string[] };
273273

274274
/**
275-
* Resolve the on-screen layout from the URL params and the runtime gates. `cols`
276-
* carries the full column order; a `-`-prefixed token is hidden but keeps its
277-
* position. When `cols` is absent the default layout (all available standard
278-
* columns in default order, nothing hidden) is returned and `sc` is ignored.
275+
* The order columns take when `cols` is absent: standard columns in default
276+
* order, then smart columns in their `sc` definition order.
277+
*/
278+
function canonicalOrder(available: StandardColumnDef[], smartCount: number): string[] {
279+
return [
280+
...available.map((def) => def.id as string),
281+
...Array.from({ length: smartCount }, (_, i) => smartColumnRef(i)),
282+
];
283+
}
284+
285+
/**
286+
* Resolve the on-screen layout from the URL params and the runtime gates.
287+
* `cols` is present only when the order differs from the default; otherwise the
288+
* default order is used. `hide` lists the columns that are hidden but still
289+
* occupy their slot, so hiding a column does not rewrite the whole order.
279290
*/
280291
export function resolveColumnLayout(
281-
params: { cols: string[]; sc: string[] },
292+
params: ColumnLayoutParams,
282293
runtime: RunColumnRuntime
283294
): ColumnLayout {
284295
const available = availableStandardColumns(runtime);
285296
const availableById = new Map(available.map((c) => [c.id, c] as const));
286297
const smartColumns = params.sc
287298
.map(decodeSmartColumn)
288299
.filter((c): c is SmartColumnDef => c !== undefined);
300+
const hideSet = new Set(params.hide);
289301

290-
if (params.cols.length === 0) {
291-
const ordered = available.map<LayoutColumn>((def) => ({
292-
col: { kind: "standard", def },
293-
hidden: false,
294-
}));
295-
return { ordered, visible: ordered.map((o) => o.col), smartColumns, isCustomized: false };
296-
}
302+
const baseTokens =
303+
params.cols.length > 0 ? params.cols : canonicalOrder(available, smartColumns.length);
297304

298305
const ordered: LayoutColumn[] = [];
299306
const seenStandard = new Set<RunColumnId>();
307+
const seenSmart = new Set<number>();
300308

301-
for (const token of params.cols) {
302-
const hidden = token.startsWith(HIDDEN_PREFIX);
303-
const base = hidden ? token.slice(HIDDEN_PREFIX.length) : token;
304-
305-
const smartIndex = parseSmartColumnRef(base);
309+
for (const token of baseTokens) {
310+
const smartIndex = parseSmartColumnRef(token);
306311
if (smartIndex !== undefined) {
307312
const def = smartColumns[smartIndex];
308-
if (def) ordered.push({ col: { kind: "smart", index: smartIndex, def }, hidden });
313+
if (!def || seenSmart.has(smartIndex)) continue;
314+
ordered.push({ col: { kind: "smart", index: smartIndex, def }, hidden: hideSet.has(token) });
315+
seenSmart.add(smartIndex);
309316
continue;
310317
}
311318

312-
if (seenStandard.has(base as RunColumnId)) continue;
313-
const def = availableById.get(base as RunColumnId);
319+
if (seenStandard.has(token as RunColumnId)) continue;
320+
const def = availableById.get(token as RunColumnId);
314321
if (!def) continue;
315-
ordered.push({ col: { kind: "standard", def }, hidden: hidden && !def.locked });
322+
ordered.push({
323+
col: { kind: "standard", def },
324+
hidden: hideSet.has(token) && !def.locked,
325+
});
316326
seenStandard.add(def.id);
317327
}
318328

319329
ensureAllStandardColumnsPresent(ordered, seenStandard, available);
320330

331+
for (let i = 0; i < smartColumns.length; i++) {
332+
if (seenSmart.has(i)) continue;
333+
ordered.push({
334+
col: { kind: "smart", index: i, def: smartColumns[i] },
335+
hidden: hideSet.has(smartColumnRef(i)),
336+
});
337+
}
338+
321339
const visible = ordered.filter((o) => !o.hidden).map((o) => o.col);
322-
return { ordered, visible, smartColumns, isCustomized: true };
340+
const isCustomized = params.cols.length > 0 || params.hide.length > 0 || smartColumns.length > 0;
341+
return { ordered, visible, smartColumns, isCustomized };
323342
}
324343

325344
/**
@@ -349,25 +368,16 @@ function ensureAllStandardColumnsPresent(
349368
}
350369

351370
/**
352-
* Serialize a layout back to `cols`/`sc` params. Returns empty arrays for the
353-
* default layout so the URL stays clean (the caller deletes both keys).
371+
* Serialize a layout to compact `cols`/`sc`/`hide` params. `cols` is omitted
372+
* whenever the order still matches the default, so hiding a column produces just
373+
* a `hide` entry rather than the entire ordered list. All arrays empty means the
374+
* default layout, and the caller deletes the keys.
354375
*/
355376
export function encodeColumnLayout(
356377
ordered: LayoutColumn[],
357378
runtime: RunColumnRuntime
358-
): { cols: string[]; sc: string[] } {
379+
): EncodedColumnLayout {
359380
const available = availableStandardColumns(runtime);
360-
const hasSmart = ordered.some((o) => o.col.kind === "smart");
361-
const isDefault =
362-
!hasSmart &&
363-
ordered.length === available.length &&
364-
ordered.every(
365-
(o, i) => o.col.kind === "standard" && o.col.def.id === available[i]?.id && !o.hidden
366-
);
367-
368-
if (isDefault) {
369-
return { cols: [], sc: [] };
370-
}
371381

372382
const sc: string[] = [];
373383
const smartRefByIndex = new Map<number, string>();
@@ -379,12 +389,31 @@ export function encodeColumnLayout(
379389
}
380390
}
381391

382-
const cols = ordered.map(({ col, hidden }) => {
383-
const base = col.kind === "standard" ? col.def.id : (smartRefByIndex.get(col.index) as string);
384-
return hidden ? `${HIDDEN_PREFIX}${base}` : base;
385-
});
392+
const tokenFor = (col: ResolvedColumn) =>
393+
col.kind === "standard" ? (col.def.id as string) : (smartRefByIndex.get(col.index) as string);
394+
395+
const baseTokens = ordered.map(({ col }) => tokenFor(col));
396+
const hide = ordered.filter((o) => o.hidden).map(({ col }) => tokenFor(col));
397+
398+
const canonical = canonicalOrder(available, sc.length);
399+
const orderIsDefault =
400+
baseTokens.length === canonical.length && baseTokens.every((t, i) => t === canonical[i]);
386401

387-
return { cols, sc };
402+
return { cols: orderIsDefault ? [] : baseTokens, sc, hide };
403+
}
404+
405+
/**
406+
* Parse the raw URL values into layout params. `cols` and `hide` are single
407+
* comma-joined params; `sc` is repeated.
408+
*/
409+
export function parseColumnParams(
410+
cols: string | null | undefined,
411+
sc: string[],
412+
hide: string | null | undefined
413+
): ColumnLayoutParams {
414+
const split = (value: string | null | undefined) =>
415+
value ? value.split(",").filter(Boolean) : [];
416+
return { cols: split(cols), sc, hide: split(hide) };
388417
}
389418

390419
/** The set of smart-column sources referenced by the visible layout. */

0 commit comments

Comments
 (0)