diff --git a/apps/yaak-proxy/components/FlowList.tsx b/apps/yaak-proxy/components/FlowList.tsx index edf49d933..75b4ae566 100644 --- a/apps/yaak-proxy/components/FlowList.tsx +++ b/apps/yaak-proxy/components/FlowList.tsx @@ -301,6 +301,15 @@ export function FlowList({ style, className }: Props) { el.scrollLeft += e.deltaX; }, []); + // A dragged column's drop line, from the window's coordinates to the list's + const rootRef = useRef(null); + const [dropLine, setDropLine] = useState(null); + const getScrollElement = useCallback(() => scrollRef.current, []); + const handleDropLine = useCallback((x: number | null) => { + const root = rootRef.current; + setDropLine(x == null || root == null ? null : x - root.getBoundingClientRect().left); + }, []); + // A list too short to scroll still loads older flows on scrolling up. const handleWheel = useCallback( (e: WheelEvent) => { @@ -407,10 +416,11 @@ export function FlowList({ style, className }: Props) { return (
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- scrolls the list sideways */} @@ -430,8 +440,23 @@ export function FlowList({ style, className }: Props) { onSort={handleSort} onSetSort={handleSetSort} onResetSort={follow} + scrollElement={getScrollElement} + onDropLine={handleDropLine} />
+ {/* Where a dragged column would drop, down the header and the rows in view */} + {dropLine != null && ( +
+
+
+ )}
void; onResetSort: () => void; + /** The rows' scrolling element, which a column dragged to its edge scrolls sideways */ + scrollElement: () => HTMLElement | null; + /** Where a dragged column would drop, in the window's coordinates, or null for nowhere */ + onDropLine: (x: number | null) => void; } /** * The list's only control surface: a click on a label sorts by it alone, a second reverses it * and a third goes back to arrival order; Option-click adds it to the sort as a further key. - * Dragging the edge after a label resizes its column and a double-click on it resets it. A - * right-click on a label opens the column's menu; the picker sits in the empty corner on hover. + * Dragging a label moves its column, and Escape puts it back. Dragging the edge after a label + * resizes its column and a double-click on it resets it. A right-click on a label opens the + * column's menu; the picker sits in the empty corner on hover. */ export const FlowListHeader = memo(function FlowListHeader({ columns, @@ -60,6 +94,8 @@ export const FlowListHeader = memo(function FlowListHeader({ onSort, onSetSort: setSort, onResetSort, + scrollElement, + onDropLine, }: Props) { const setPrefs = useSetAtom(columnPrefsAtom); const unavailable = unavailableColumns(useAtomValue(hasAppsAtom)); @@ -67,6 +103,12 @@ export const FlowListHeader = memo(function FlowListHeader({ const [menu, setMenu] = useState<{ x: number; y: number; column: ColumnDef | null } | null>(null); // Widths when a drag began, so each move sets the width from where it started. const startWidth = useRef(0); + const { dragging, sensors, dndHandlers, gridRef } = useColumnDrag({ + columns, + scrollElement, + onDropLine, + onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)), + }); // Taking out the sort's only key goes back to arrival order, as Reset Sort does const removeColumn = (id: CustomColumnId) => { @@ -110,11 +152,17 @@ export const FlowListHeader = memo(function FlowListHeader({ { type: "separator" }, { label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort }, { label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) }, + { + label: "Reset Column Order", + disabled: prefs.order.length === 0, + onSelect: () => setPrefs((p) => resetOrder(p)), + }, ]; const columnItems = (c: ColumnDef): DropdownItem[] => { const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]); const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending)); + const at = columns.findIndex((col) => col.id === c.id); return [ { label: "Sort Ascending", onSelect: () => sortBy(false) }, { label: "Sort Descending", onSelect: () => sortBy(true) }, @@ -122,6 +170,17 @@ export const FlowListHeader = memo(function FlowListHeader({ { label: "Add to Sort Descending", onSelect: () => addTo(true) }, { label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort }, { type: "separator" }, + { + label: "Move Left", + disabled: at <= 0, + onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, -1, unavailable)), + }, + { + label: "Move Right", + disabled: at === -1 || at >= columns.length - 1, + onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, 1, unavailable)), + }, + { type: "separator" }, { label: "Hide Column", disabled: !canHide(prefs, c.id, unavailable), @@ -163,6 +222,7 @@ export const FlowListHeader = memo(function FlowListHeader({
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
- {columns.map((c) => { - const at = sort.findIndex((k) => k.column === c.id); - const key = sort[at]; - const label = ( - <> - {key != null && ( - - )} - {key != null && sort.length > 1 && ( - {at + 1} - )} - {c.id === "app" ? "" : c.label} - - ); - const labelClass = classNames( - "flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap", - c.align === "right" && "justify-end", - key != null && "text-text-subtle", - ); - return ( - + {columns.map((c) => ( + - - {/* On the column's right edge, the boundary with the next, resizing this one */} - { - startWidth.current = c.current; - }} - onResizeMove={(e) => - setPrefs((p) => resizeColumn(p, c.id, startWidth.current + e.x - e.xStart)) - } - onReset={() => setPrefs((p) => resetWidth(p, c.id))} - /> - - ); - })} + column={c} + sort={sort} + dragging={dragging?.id === c.id} + onSort={onSort} + onResizeStart={() => { + startWidth.current = c.current; + }} + onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))} + onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))} + /> + ))} + {/* Over everything, as the header clips what overflows it */} + {createPortal( + + {dragging != null && ( +
+ +
+ )} +
, + document.body, + )} + ); }); + +interface HeaderCellProps { + column: Column; + sort: FlowSortKey[]; + /** Being dragged, so it dims where it was */ + dragging: boolean; + onSort: (column: ColumnId, add: boolean) => void; + onResizeStart: () => void; + /** How far the edge moved since the resize began */ + onResizeMove: (dx: number) => void; + onResetWidth: () => void; +} + +function HeaderCell({ + column: c, + sort, + dragging, + onSort, + onResizeStart, + onResizeMove, + onResetWidth, +}: HeaderCellProps) { + // The label drags the column; the resize handle beside it is outside it, so it still resizes. + const { listeners, setNodeRef } = useDraggable({ id: c.id }); + const sorted = sort.some((k) => k.column === c.id); + return ( + + + {/* On the column's right edge, the boundary with the next, resizing this one */} + onResizeMove(e.x - e.xStart)} + onReset={onResetWidth} + /> + + ); +} + +/** + * A column's name, after its sort direction and, sorted by several, its place in the sort. App's + * column is as narrow as its icons, so only its dragged ghost names it. + */ +function ColumnLabel({ + column: c, + sort, + ghost = false, +}: { + column: Column; + sort: FlowSortKey[]; + ghost?: boolean; +}) { + const at = sort.findIndex((k) => k.column === c.id); + const key = sort[at]; + return ( + <> + {key != null && ( + + )} + {key != null && sort.length > 1 && ( + {at + 1} + )} + {c.id === "app" && !ghost ? "" : c.label} + + ); +} + +/** + * Dragging a column's label: where it would drop, the boundary between the labels nearest the + * pointer, and the list scrolling sideways while the pointer is near its edge. The columns only + * move on the drop, so the rows lay out once. + */ +function useColumnDrag({ + columns, + scrollElement, + onDropLine, + onDrop, +}: { + columns: Column[]; + scrollElement: () => HTMLElement | null; + onDropLine: (x: number | null) => void; + /** Moves the column to the boundary `to` between those shown */ + onDrop: (id: ColumnId, to: number) => void; +}) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: DRAG_DISTANCE } }), + ); + const gridRef = useRef(null); + const [dragging, setDragging] = useState(null); + // Live while dragging, read each frame + const state = useRef<{ + /** The column dragged */ + id: ColumnId; + /** The pointer when the press began, and now */ + start: number; + x: number; + /** The boundary it would drop at, and that boundary's line, or null where it would not move */ + to: number | null; + line: number | null; + frame: number; + } | null>(null); + const latest = useRef({ columns, scrollElement, onDropLine, onDrop }); + useLayoutEffect(() => { + latest.current = { columns, scrollElement, onDropLine, onDrop }; + }); + + const stop = useCallback(() => { + const s = state.current; + state.current = null; + if (s != null) cancelAnimationFrame(s.frame); + setDragging(null); + latest.current.onDropLine(null); + return s; + }, []); + + // A drag never outlives the header + useEffect( + () => () => { + stop(); + }, + [stop], + ); + + const onDragStart = useCallback((e: DragStartEvent) => { + const column = latest.current.columns.find((c) => c.id === e.active.id); + if (column == null) return; + const x = pointerX(e); + state.current = { id: column.id, start: x, x, to: null, line: null, frame: 0 }; + setDragging(column); + + const tick = () => { + const s = state.current; + if (s == null) return; + // Near an edge, the list scrolls that way, faster the nearer; the header follows it. + const list = latest.current.scrollElement(); + if (list != null) { + const { left, right } = list.getBoundingClientRect(); + const near = + s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0; + if (near !== 0) { + const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED; + list.scrollLeft += Math.sign(near) * Math.max(1, speed); + } + } + // The boundary before the first label whose middle is right of the pointer + const cells = Array.from( + gridRef.current?.querySelectorAll(":scope > [data-column]") ?? [], + ).map((el) => el.getBoundingClientRect()); + let to = cells.findIndex((r) => s.x < r.left + r.width / 2); + if (to === -1) to = cells.length; + // Where it already is, it would not move, so no line. Its place is read each frame, as + // the columns can change under a drag: Operation shows itself when a flow has one. + const from = latest.current.columns.findIndex((c) => c.id === s.id); + const moves = from !== -1 && to !== from && to !== from + 1; + s.to = moves ? to : null; + const line = moves ? (cells[to]?.left ?? cells.at(-1)?.right ?? null) : null; + // Told again only when it changes: another boundary, or the same one scrolled + if (line !== s.line) latest.current.onDropLine(line); + s.line = line; + s.frame = requestAnimationFrame(tick); + }; + state.current.frame = requestAnimationFrame(tick); + }, []); + + // The pointer, from where the press began; the frames above read it, scrolling or not. + const onDragMove = useCallback((e: DragMoveEvent) => { + const s = state.current; + if (s != null) s.x = s.start + e.delta.x; + }, []); + + const onDragEnd = useCallback(() => { + const s = stop(); + if (s?.to != null) latest.current.onDrop(s.id, s.to); + }, [stop]); + + return { + dragging, + sensors, + gridRef, + dndHandlers: { onDragStart, onDragMove, onDragEnd, onDragCancel: stop }, + }; +} + +/** Where the press that began a drag was, across the window */ +function pointerX(e: DragStartEvent): number { + const event = e.activatorEvent; + return event instanceof globalThis.MouseEvent ? event.clientX : 0; +} diff --git a/apps/yaak-proxy/lib/columns.test.ts b/apps/yaak-proxy/lib/columns.test.ts index 95b3dcce9..446b3c71c 100644 --- a/apps/yaak-proxy/lib/columns.test.ts +++ b/apps/yaak-proxy/lib/columns.test.ts @@ -4,6 +4,7 @@ import customSortFixture from "../../../crates-proxy/yaak-proxy-lib/tests/fixtur import { addCustomColumn, addSortKey, + allColumns, cellFilterTerm, type ColumnPrefs, customPath, @@ -15,10 +16,13 @@ import { gridTemplate, gridWidth, hideOtherColumns, + moveColumn, + moveColumnBy, nextSort, operationOf, parseColumnPrefs, removeCustomColumn, + resetOrder, resetWidth, resetWidths, resizeColumn, @@ -121,6 +125,7 @@ describe("column preferences", () => { visible: { number: false, "field:header.x-cache": true }, widths: { host: 60 }, custom: ["field:header.x-cache"], + order: [], sort: [{ column: "status", descending: true }], }); expect( @@ -138,6 +143,7 @@ describe("column preferences", () => { visible: {}, widths: { size: 90 }, custom: ["field:query.page"], + order: [], sort: [{ column: "time", descending: false }], }); expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs); @@ -181,6 +187,150 @@ describe("column preferences", () => { }); }); +describe("column order", () => { + const defaults = ids(defaultColumnPrefs); + + test("a column moves to the boundary it was dropped at", () => { + expect(ids(moveColumn(defaultColumnPrefs, "status", 2))).toEqual([ + "number", + "app", + "status", + "method", + "host", + "path", + "query", + "type", + "size", + "time", + ]); + // Rightwards, before the column at the boundary + expect(ids(moveColumn(defaultColumnPrefs, "method", 5)).slice(2, 6)).toEqual([ + "host", + "path", + "method", + "query", + ]); + // To either end + expect(ids(moveColumn(defaultColumnPrefs, "time", 0))[0]).toBe("time"); + expect(ids(moveColumn(defaultColumnPrefs, "number", defaults.length)).at(-1)).toBe("number"); + // The picker lists them in the same order + const moved = moveColumn(defaultColumnPrefs, "time", 0); + expect(allColumns(moved)[0]?.id).toBe("time"); + }); + + test("a drop where the column already is changes nothing", () => { + expect(moveColumn(defaultColumnPrefs, "host", 3)).toBe(defaultColumnPrefs); + expect(moveColumn(defaultColumnPrefs, "host", 4)).toBe(defaultColumnPrefs); + expect(moveColumn(defaultColumnPrefs, "host", -1)).toBe(defaultColumnPrefs); + expect(moveColumn(defaultColumnPrefs, "host", defaults.length + 1)).toBe(defaultColumnPrefs); + // A hidden column has no place among those shown + expect(moveColumn(defaultColumnPrefs, "latency", 0)).toBe(defaultColumnPrefs); + }); + + test("Move Left and Move Right go one place, and not past the ends", () => { + expect(ids(moveColumnBy(defaultColumnPrefs, "host", -1)).slice(2, 5)).toEqual([ + "host", + "method", + "path", + ]); + expect(ids(moveColumnBy(defaultColumnPrefs, "host", 1)).slice(3, 6)).toEqual([ + "path", + "host", + "query", + ]); + expect(moveColumnBy(defaultColumnPrefs, "number", -1)).toBe(defaultColumnPrefs); + expect(moveColumnBy(defaultColumnPrefs, "time", 1)).toBe(defaultColumnPrefs); + // Where App has nothing to show, Number is next to Method + expect( + shownColumns(moveColumnBy(defaultColumnPrefs, "method", -1, ["app"]), ["app"]).map( + (c) => c.id, + )[0], + ).toBe("method"); + }); + + test("a hidden column keeps its place and comes back to it", () => { + let prefs = moveColumn(defaultColumnPrefs, "time", 0); + prefs = toggleColumn(prefs, "status"); + prefs = moveColumn(prefs, "host", 0); + expect(ids(prefs)).not.toContain("status"); + prefs = toggleColumn(prefs, "status"); + expect(ids(prefs)).toEqual([ + "host", + "time", + "number", + "app", + "method", + "path", + "query", + "status", + "type", + "size", + ]); + }); + + test("a column turned on goes at the end, unless it had a place", () => { + // Request Size's default place is before Size + let prefs = toggleColumn(defaultColumnPrefs, "requestSize"); + expect(ids(prefs).at(-1)).toBe("requestSize"); + expect(ids(prefs).slice(0, -1)).toEqual(defaults); + // Moved, hidden and shown again, it goes back where it was moved to + prefs = moveColumn(prefs, "requestSize", defaults.indexOf("size")); + prefs = toggleColumn(toggleColumn(prefs, "requestSize"), "requestSize"); + expect(ids(prefs).slice(-3)).toEqual(["requestSize", "size", "time"]); + // Already last of those shown, nothing needs placing + expect(toggleColumn(defaultColumnPrefs, "remote").order).toEqual([]); + }); + + test("a custom column is added at the end and leaves the order when removed", () => { + const moved = moveColumn(defaultColumnPrefs, "size", 0); + let prefs = addCustomColumn(moved, "header.x-cache"); + expect(ids(prefs).at(-1)).toBe("field:header.x-cache"); + prefs = moveColumn(prefs, "field:header.x-cache", 1); + expect(ids(prefs).slice(0, 2)).toEqual(["size", "field:header.x-cache"]); + // Hidden and added again, it keeps its place + prefs = addCustomColumn(toggleColumn(prefs, "field:header.x-cache"), "header.x-cache"); + expect(ids(prefs)[1]).toBe("field:header.x-cache"); + prefs = removeCustomColumn(prefs, "field:header.x-cache"); + expect(prefs.order).not.toContain("field:header.x-cache"); + expect(ids(prefs)).toEqual(ids(moved)); + // Added again, it starts over at the end + expect(ids(addCustomColumn(prefs, "header.x-cache")).at(-1)).toBe("field:header.x-cache"); + }); + + test("the order persists, and columns it doesn't list follow it in their default order", () => { + let prefs = addCustomColumn(defaultColumnPrefs, "query.page"); + prefs = moveColumn(prefs, "field:query.page", 0); + expect(parseColumnPrefs(JSON.parse(JSON.stringify(prefs))).order).toEqual(prefs.order); + const parsed = parseColumnPrefs({ + custom: ["field:query.page"], + order: ["time", "gone", 3, "time", "field:header.x-gone", "field:query.page", "host"], + }); + expect(parsed.order).toEqual(["time", "field:query.page", "host"]); + expect(ids(parsed)).toEqual([ + "time", + "field:query.page", + "host", + "number", + "app", + "method", + "path", + "query", + "status", + "type", + "size", + ]); + expect(parseColumnPrefs({ order: "time" }).order).toEqual([]); + }); + + test("Reset Column Order puts every column back", () => { + let prefs = moveColumn(defaultColumnPrefs, "time", 0); + prefs = toggleColumn(prefs, "latency"); + prefs = resetOrder(prefs); + expect(prefs.order).toEqual([]); + expect(ids(prefs)).toEqual([...defaults.slice(0, -1), "time", "latency"]); + }); +}); + describe("sorting", () => { const one = (column: FlowSortKey["column"], descending: boolean) => [{ column, descending }]; diff --git a/apps/yaak-proxy/lib/columns.ts b/apps/yaak-proxy/lib/columns.ts index f94655389..e55e2b4a2 100644 --- a/apps/yaak-proxy/lib/columns.ts +++ b/apps/yaak-proxy/lib/columns.ts @@ -145,26 +145,45 @@ export const CELL_PADDING = "0.75rem"; /** * What the person chose: which columns show, the widths they dragged, by id, the header and - * query parameter columns they added, in order, and the list's sort + * query parameter columns they added, in order, the order they dragged the columns into, and the + * list's sort */ export interface ColumnPrefs { visible: Partial>; widths: Partial>; custom: CustomColumnId[]; + /** + * The columns' order, by id. Columns not listed follow the listed ones, in their default order; + * empty for the default order. + */ + order: ColumnId[]; /** The keys in order; empty for the order flows arrive in */ sort: FlowSortKey[]; } -export const defaultColumnPrefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], sort: [] }; +export const defaultColumnPrefs: ColumnPrefs = { + visible: {}, + widths: {}, + custom: [], + order: [], + sort: [], +}; export interface Column extends ColumnDef { /** Its width now, in pixels of content */ current: number; } -/** Every column the picker lists: the built-in ones, then those the person added */ +/** + * Every column, in the order the person left them: those they placed, then the rest in their + * default order, the built-in ones before those added. The picker lists them so. + */ export function allColumns(prefs: ColumnPrefs): ColumnDef[] { - return [...COLUMNS, ...prefs.custom.map(customDef)]; + const defaults = [...COLUMNS, ...prefs.custom.map(customDef)]; + if (prefs.order.length === 0) return defaults; + const byId = new Map(defaults.map((c) => [c.id, c])); + const placed = prefs.order.flatMap((id) => byId.get(id) ?? []); + return [...placed, ...defaults.filter((c) => !prefs.order.includes(c.id))]; } function isVisible(prefs: ColumnPrefs, def: ColumnDef): boolean { @@ -217,7 +236,66 @@ export function toggleColumn( ): ColumnPrefs { const shown = isVisible(prefs, columnDef(id)); if (shown && !canHide(prefs, id, unavailable)) return prefs; - return { ...prefs, visible: { ...prefs.visible, [id]: !shown } }; + const next = { ...prefs, visible: { ...prefs.visible, [id]: !shown } }; + return shown ? next : placeAtEnd(next, id); +} + +/** + * Puts a column just shown after the others shown, unless the person placed it before, so it + * goes back to that place. The columns shown before it are placed too, where they are, so the + * ones not listed don't jump ahead of it. + */ +function placeAtEnd(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs { + if (prefs.order.includes(id)) return prefs; + const all = allColumns(prefs); + if (all.filter((c) => isVisible(prefs, c)).at(-1)?.id === id) return prefs; + const placed = all.filter( + (c) => c.id !== id && (prefs.order.includes(c.id) || isVisible(prefs, c)), + ); + return { ...prefs, order: [...placed.map((c) => c.id), id] }; +} + +/** + * Moves a shown column to the boundary `to` between the columns shown: 0 before the first, their + * count after the last. Hidden columns keep their places among the others. + */ +export function moveColumn( + prefs: ColumnPrefs, + id: ColumnId, + to: number, + unavailable: ColumnId[] = [], +): ColumnPrefs { + const shown = shownColumns(prefs, unavailable).map((c) => c.id); + const from = shown.indexOf(id); + if (from === -1 || to < 0 || to > shown.length || to === from || to === from + 1) return prefs; + // Every column shown, and those hidden that were placed, so they keep their places + const order = allColumns(prefs) + .filter((c) => c.id !== id && (prefs.order.includes(c.id) || shown.includes(c.id))) + .map((c) => c.id); + // Before the column at the boundary, or after the last one shown + const before = shown[to]; + const last = shown.at(-1); + const at = before != null ? order.indexOf(before) : last == null ? -1 : order.indexOf(last) + 1; + if (at === -1) return prefs; + order.splice(at, 0, id); + return { ...prefs, order }; +} + +/** One place left, or with `1`, right, among the columns shown */ +export function moveColumnBy( + prefs: ColumnPrefs, + id: ColumnId, + by: -1 | 1, + unavailable: ColumnId[] = [], +): ColumnPrefs { + const from = shownColumns(prefs, unavailable).findIndex((c) => c.id === id); + if (from === -1) return prefs; + return moveColumn(prefs, id, by === -1 ? from - 1 : from + 2, unavailable); +} + +/** Every column back to its default place */ +export function resetOrder(prefs: ColumnPrefs): ColumnPrefs { + return { ...prefs, order: [] }; } /** Shows `id` alone */ @@ -227,20 +305,24 @@ export function hideOtherColumns(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs return { ...prefs, visible }; } -/** Adds a column for a header or query parameter's path, shown at the end; already there, shown */ +/** + * Adds a column for a header or query parameter's path, shown at the end; already there, shown, + * in its place + */ export function addCustomColumn(prefs: ColumnPrefs, path: string): ColumnPrefs { const id = customColumnId(path); const custom = prefs.custom.includes(id) ? prefs.custom : [...prefs.custom, id]; - return { ...prefs, custom, visible: { ...prefs.visible, [id]: true } }; + return placeAtEnd({ ...prefs, custom, visible: { ...prefs.visible, [id]: true } }, id); } -/** Takes the column out, and out of the sort */ +/** Takes the column out, and out of the order and the sort */ export function removeCustomColumn(prefs: ColumnPrefs, id: CustomColumnId): ColumnPrefs { const { [id]: _v, ...visible } = prefs.visible; const { [id]: _w, ...widths } = prefs.widths; const custom = prefs.custom.filter((c) => c !== id); + const order = prefs.order.filter((c) => c !== id); const sort = prefs.sort.filter((k) => k.column !== id); - return { ...prefs, custom, visible, widths, sort }; + return { ...prefs, custom, visible, widths, order, sort }; } export function resizeColumn(prefs: ColumnPrefs, id: ColumnId, width: number): ColumnPrefs { @@ -270,9 +352,9 @@ export function showOperationFor(prefs: ColumnPrefs, flows: Flow[]): ColumnPrefs * wrong type, so a change to the columns never breaks the list. */ export function parseColumnPrefs(value: unknown): ColumnPrefs { - const prefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], sort: [] }; + const prefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], order: [], sort: [] }; if (value == null || typeof value !== "object") return prefs; - const { visible, widths, custom, sort } = value as Record; + const { visible, widths, custom, order, sort } = value as Record; if (Array.isArray(custom)) { for (const id of custom) { if (typeof id !== "string" || !id.startsWith("field:")) continue; @@ -288,6 +370,15 @@ export function parseColumnPrefs(value: unknown): ColumnPrefs { const w = (widths as Record | undefined)?.[def.id]; if (typeof w === "number" && Number.isFinite(w) && w > 0) prefs.widths[def.id] = w; } + // Columns no longer there are dropped; those not listed follow the rest in their default order + if (Array.isArray(order)) { + for (const column of order) { + if (typeof column !== "string") continue; + const id = column as ColumnId; + const known = isCustomColumn(id) ? prefs.custom.includes(id) : DEFS.has(id); + if (known && !prefs.order.includes(id)) prefs.order.push(id); + } + } if (Array.isArray(sort)) { for (const key of sort) { if (key == null || typeof key !== "object") continue; diff --git a/apps/yaak-proxy/package.json b/apps/yaak-proxy/package.json index 7e160c0b9..dc2baf4ba 100644 --- a/apps/yaak-proxy/package.json +++ b/apps/yaak-proxy/package.json @@ -9,6 +9,7 @@ "lint": "tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", "@tanstack/react-query": "^5.105.0", "@tanstack/react-virtual": "^3.14.24", "@tauri-apps/api": "^2.12.4", diff --git a/package-lock.json b/package-lock.json index 1238d6bc1..754dd06ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -246,6 +246,7 @@ "name": "@yaakapp/yaak-proxy", "version": "1.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", "@tanstack/react-query": "^5.105.0", "@tanstack/react-virtual": "^3.14.24", "@tauri-apps/api": "^2.12.4",