Drag Yaak Proxy's flow list columns to reorder them

A column's label drags it: a lifted ghost follows the pointer, a line marks the
boundary it would drop at, down the header and the rows in view, and the list
scrolls sideways near its edges. A press that doesn't move still sorts, the
resize handles still resize, and Escape cancels. The order is kept with the
other column preferences as a list of ids, and the header menu gains Move Left
and Move Right, the columns menu Reset Column Order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and Stetta task tk_mwadri4inaohxyiw committed 2026-11-10 01:10:35 -07:00
1 parent e870604607
commit 4e63ba2ce7
6 files changed
+592 -68

No files matched your search

+26 -1
View File
@@ -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<HTMLDivElement>(null);
const [dropLine, setDropLine] = useState<number | null>(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<HTMLDivElement>) => {
@@ -407,10 +416,11 @@ export function FlowList({ style, className }: Props) {
return (
<div
ref={rootRef}
style={style}
className={classNames(
className,
"grid grid-rows-[auto_minmax(0,1fr)] grid-cols-[minmax(0,1fr)] min-h-0 text-sm",
"relative grid grid-rows-[auto_minmax(0,1fr)] grid-cols-[minmax(0,1fr)] min-h-0 text-sm",
)}
>
{/* 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}
/>
</div>
{/* Where a dragged column would drop, down the header and the rows in view */}
{dropLine != null && (
<div
aria-hidden
style={{ right: scrollbarWidth }}
className="pointer-events-none absolute inset-y-0 left-0 overflow-hidden"
>
<div
style={{ left: dropLine }}
className="absolute inset-y-0 w-0.5 -translate-x-1/2 bg-primary"
/>
</div>
)}
<div className="relative min-h-0">
<div
ref={scrollRef}
+312 -56
View File
@@ -1,3 +1,13 @@
import {
DndContext,
DragOverlay,
type DragMoveEvent,
type DragStartEvent,
PointerSensor,
useDraggable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { FlowSortKey } from "@yaakapp-internal/proxy-lib";
import {
ContextMenu,
@@ -9,7 +19,16 @@ import {
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue, useSetAtom } from "jotai";
import { memo, type MouseEvent, useCallback, useRef, useState } from "react";
import {
memo,
type MouseEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import {
addSortKey,
allColumns,
@@ -21,7 +40,10 @@ import {
canHide,
hideOtherColumns,
isCustomColumn,
moveColumn,
moveColumnBy,
removeCustomColumn,
resetOrder,
resetWidth,
resetWidths,
resizeColumn,
@@ -31,6 +53,13 @@ import {
import { columnPrefsAtom, hasAppsAtom } from "../lib/store";
import { addColumnDialogAtom } from "./AddColumnDialog";
/** Pixels a press on a label moves before it drags the column rather than sorting by it */
const DRAG_DISTANCE = 4;
/** How near the list's side edge, in pixels, a dragged column scrolls the list that way */
const EDGE = 40;
/** The most the list scrolls sideways a frame, at the very edge */
const EDGE_SPEED = 18;
interface Props {
columns: Column[];
template: string;
@@ -43,13 +72,18 @@ interface Props {
/** Sets the sort from the menus; empty goes back to arrival order */
onSetSort: (sort: FlowSortKey[]) => 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({
<div style={{ width, minWidth: "100%" }} className="px-1.5">
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
<div
ref={gridRef}
onContextMenu={openMenu}
style={{ gridTemplateColumns: template }}
className={classNames(
@@ -182,60 +242,40 @@ export const FlowListHeader = memo(function FlowListHeader({
/>
</Dropdown>
</span>
{columns.map((c) => {
const at = sort.findIndex((k) => k.column === c.id);
const key = sort[at];
const label = (
<>
{key != null && (
<Icon
icon="chevron_down"
size="xs"
className={classNames("shrink-0", !key.descending && "rotate-180")}
/>
)}
{key != null && sort.length > 1 && (
<span className="shrink-0 -ml-0.5 text-2xs tabular-nums">{at + 1}</span>
)}
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
</>
);
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 (
<span
<DndContext sensors={sensors} autoScroll={false} {...dndHandlers}>
{columns.map((c) => (
<HeaderCell
key={c.id}
data-column={c.id}
className="relative h-full flex items-center min-w-0 px-1.5"
>
<button
type="button"
onClick={(e) => onSort(c.id, e.altKey)}
// No tooltip: the header sits under the search field, and a tooltip here covered it
aria-label={`Sort by ${c.label}`}
className={classNames(labelClass, "hover:text-text-subtle")}
>
{label}
</button>
{/* On the column's right edge, the boundary with the next, resizing this one */}
<ResizeHandle
side="right"
justify="center"
className="absolute top-0 translate-x-1/2"
onResizeStart={() => {
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))}
/>
</span>
);
})}
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(
<DragOverlay dropAnimation={null}>
{dragging != null && (
<div
className={classNames(
// As wide as its name, however narrow the column
"flex items-center gap-0.5 h-full w-max px-1.5 rounded-sm",
"cursor-grabbing whitespace-nowrap",
"text-xs text-text bg-surface-highlight border border-border shadow-lg",
)}
>
<ColumnLabel column={dragging} sort={sort} ghost />
</div>
)}
</DragOverlay>,
document.body,
)}
</DndContext>
<ContextMenu
triggerPosition={menu}
items={menu == null ? [] : menu.column == null ? pickerItems : columnItems(menu.column)}
@@ -245,3 +285,219 @@ export const FlowListHeader = memo(function FlowListHeader({
</div>
);
});
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 (
<span
data-column={c.id}
className={classNames(
"relative h-full flex items-center min-w-0 px-1.5",
dragging && "opacity-40",
)}
>
<button
ref={setNodeRef}
type="button"
{...listeners}
// A press that moves drags instead; dnd-kit swallows the click that ends a drag.
onClick={(e) => onSort(c.id, e.altKey)}
// No tooltip: the header sits under the search field, and a tooltip here covered it
aria-label={`Sort by ${c.label}`}
className={classNames(
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap",
"hover:text-text-subtle",
c.align === "right" && "justify-end",
sorted && "text-text-subtle",
)}
>
<ColumnLabel column={c} sort={sort} />
</button>
{/* On the column's right edge, the boundary with the next, resizing this one */}
<ResizeHandle
side="right"
justify="center"
className="absolute top-0 translate-x-1/2"
onResizeStart={onResizeStart}
onResizeMove={(e) => onResizeMove(e.x - e.xStart)}
onReset={onResetWidth}
/>
</span>
);
}
/**
* 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 && (
<Icon
icon="chevron_down"
size="xs"
className={classNames("shrink-0", !key.descending && "rotate-180")}
/>
)}
{key != null && sort.length > 1 && (
<span className="shrink-0 -ml-0.5 text-2xs tabular-nums">{at + 1}</span>
)}
<span className="truncate">{c.id === "app" && !ghost ? "" : c.label}</span>
</>
);
}
/**
* 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<HTMLDivElement>(null);
const [dragging, setDragging] = useState<Column | null>(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<HTMLElement>(":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;
}
+150
View File
@@ -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 }];
+102 -11
View File
@@ -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<Record<ColumnId, boolean>>;
widths: Partial<Record<ColumnId, number>>;
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<string, unknown>;
const { visible, widths, custom, order, sort } = value as Record<string, unknown>;
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<string, unknown> | 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;
+1
View File
@@ -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",
+1
View File
@@ -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",