mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
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:
1 parent
e870604607
commit
4e63ba2ce7
6 files changed
+592
-68
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user