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;
|
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.
|
// A list too short to scroll still loads older flows on scrolling up.
|
||||||
const handleWheel = useCallback(
|
const handleWheel = useCallback(
|
||||||
(e: WheelEvent<HTMLDivElement>) => {
|
(e: WheelEvent<HTMLDivElement>) => {
|
||||||
@@ -407,10 +416,11 @@ export function FlowList({ style, className }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={rootRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
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 */}
|
{/* 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}
|
onSort={handleSort}
|
||||||
onSetSort={handleSetSort}
|
onSetSort={handleSetSort}
|
||||||
onResetSort={follow}
|
onResetSort={follow}
|
||||||
|
scrollElement={getScrollElement}
|
||||||
|
onDropLine={handleDropLine}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 className="relative min-h-0">
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
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 type { FlowSortKey } from "@yaakapp-internal/proxy-lib";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
@@ -9,7 +19,16 @@ import {
|
|||||||
} from "@yaakapp-internal/ui";
|
} from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
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 {
|
import {
|
||||||
addSortKey,
|
addSortKey,
|
||||||
allColumns,
|
allColumns,
|
||||||
@@ -21,7 +40,10 @@ import {
|
|||||||
canHide,
|
canHide,
|
||||||
hideOtherColumns,
|
hideOtherColumns,
|
||||||
isCustomColumn,
|
isCustomColumn,
|
||||||
|
moveColumn,
|
||||||
|
moveColumnBy,
|
||||||
removeCustomColumn,
|
removeCustomColumn,
|
||||||
|
resetOrder,
|
||||||
resetWidth,
|
resetWidth,
|
||||||
resetWidths,
|
resetWidths,
|
||||||
resizeColumn,
|
resizeColumn,
|
||||||
@@ -31,6 +53,13 @@ import {
|
|||||||
import { columnPrefsAtom, hasAppsAtom } from "../lib/store";
|
import { columnPrefsAtom, hasAppsAtom } from "../lib/store";
|
||||||
import { addColumnDialogAtom } from "./AddColumnDialog";
|
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 {
|
interface Props {
|
||||||
columns: Column[];
|
columns: Column[];
|
||||||
template: string;
|
template: string;
|
||||||
@@ -43,13 +72,18 @@ interface Props {
|
|||||||
/** Sets the sort from the menus; empty goes back to arrival order */
|
/** Sets the sort from the menus; empty goes back to arrival order */
|
||||||
onSetSort: (sort: FlowSortKey[]) => void;
|
onSetSort: (sort: FlowSortKey[]) => void;
|
||||||
onResetSort: () => 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
|
* 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.
|
* 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
|
* Dragging a label moves its column, and Escape puts it back. Dragging the edge after a label
|
||||||
* right-click on a label opens the column's menu; the picker sits in the empty corner on hover.
|
* 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({
|
export const FlowListHeader = memo(function FlowListHeader({
|
||||||
columns,
|
columns,
|
||||||
@@ -60,6 +94,8 @@ export const FlowListHeader = memo(function FlowListHeader({
|
|||||||
onSort,
|
onSort,
|
||||||
onSetSort: setSort,
|
onSetSort: setSort,
|
||||||
onResetSort,
|
onResetSort,
|
||||||
|
scrollElement,
|
||||||
|
onDropLine,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const setPrefs = useSetAtom(columnPrefsAtom);
|
const setPrefs = useSetAtom(columnPrefsAtom);
|
||||||
const unavailable = unavailableColumns(useAtomValue(hasAppsAtom));
|
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);
|
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.
|
// Widths when a drag began, so each move sets the width from where it started.
|
||||||
const startWidth = useRef(0);
|
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
|
// Taking out the sort's only key goes back to arrival order, as Reset Sort does
|
||||||
const removeColumn = (id: CustomColumnId) => {
|
const removeColumn = (id: CustomColumnId) => {
|
||||||
@@ -110,11 +152,17 @@ export const FlowListHeader = memo(function FlowListHeader({
|
|||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
|
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
|
||||||
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
|
{ 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 columnItems = (c: ColumnDef): DropdownItem[] => {
|
||||||
const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]);
|
const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]);
|
||||||
const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending));
|
const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending));
|
||||||
|
const at = columns.findIndex((col) => col.id === c.id);
|
||||||
return [
|
return [
|
||||||
{ label: "Sort Ascending", onSelect: () => sortBy(false) },
|
{ label: "Sort Ascending", onSelect: () => sortBy(false) },
|
||||||
{ label: "Sort Descending", onSelect: () => sortBy(true) },
|
{ 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: "Add to Sort Descending", onSelect: () => addTo(true) },
|
||||||
{ label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort },
|
{ label: "Clear Sorting", disabled: sort.length === 0, onSelect: onResetSort },
|
||||||
{ type: "separator" },
|
{ 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",
|
label: "Hide Column",
|
||||||
disabled: !canHide(prefs, c.id, unavailable),
|
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">
|
<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 */}
|
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
|
||||||
<div
|
<div
|
||||||
|
ref={gridRef}
|
||||||
onContextMenu={openMenu}
|
onContextMenu={openMenu}
|
||||||
style={{ gridTemplateColumns: template }}
|
style={{ gridTemplateColumns: template }}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@@ -182,60 +242,40 @@ export const FlowListHeader = memo(function FlowListHeader({
|
|||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</span>
|
</span>
|
||||||
{columns.map((c) => {
|
<DndContext sensors={sensors} autoScroll={false} {...dndHandlers}>
|
||||||
const at = sort.findIndex((k) => k.column === c.id);
|
{columns.map((c) => (
|
||||||
const key = sort[at];
|
<HeaderCell
|
||||||
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
|
|
||||||
key={c.id}
|
key={c.id}
|
||||||
data-column={c.id}
|
column={c}
|
||||||
className="relative h-full flex items-center min-w-0 px-1.5"
|
sort={sort}
|
||||||
>
|
dragging={dragging?.id === c.id}
|
||||||
<button
|
onSort={onSort}
|
||||||
type="button"
|
onResizeStart={() => {
|
||||||
onClick={(e) => onSort(c.id, e.altKey)}
|
startWidth.current = c.current;
|
||||||
// No tooltip: the header sits under the search field, and a tooltip here covered it
|
}}
|
||||||
aria-label={`Sort by ${c.label}`}
|
onResizeMove={(dx) => setPrefs((p) => resizeColumn(p, c.id, startWidth.current + dx))}
|
||||||
className={classNames(labelClass, "hover:text-text-subtle")}
|
onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
|
||||||
>
|
/>
|
||||||
{label}
|
))}
|
||||||
</button>
|
{/* Over everything, as the header clips what overflows it */}
|
||||||
{/* On the column's right edge, the boundary with the next, resizing this one */}
|
{createPortal(
|
||||||
<ResizeHandle
|
<DragOverlay dropAnimation={null}>
|
||||||
side="right"
|
{dragging != null && (
|
||||||
justify="center"
|
<div
|
||||||
className="absolute top-0 translate-x-1/2"
|
className={classNames(
|
||||||
onResizeStart={() => {
|
// As wide as its name, however narrow the column
|
||||||
startWidth.current = c.current;
|
"flex items-center gap-0.5 h-full w-max px-1.5 rounded-sm",
|
||||||
}}
|
"cursor-grabbing whitespace-nowrap",
|
||||||
onResizeMove={(e) =>
|
"text-xs text-text bg-surface-highlight border border-border shadow-lg",
|
||||||
setPrefs((p) => resizeColumn(p, c.id, startWidth.current + e.x - e.xStart))
|
)}
|
||||||
}
|
>
|
||||||
onReset={() => setPrefs((p) => resetWidth(p, c.id))}
|
<ColumnLabel column={dragging} sort={sort} ghost />
|
||||||
/>
|
</div>
|
||||||
</span>
|
)}
|
||||||
);
|
</DragOverlay>,
|
||||||
})}
|
document.body,
|
||||||
|
)}
|
||||||
|
</DndContext>
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
triggerPosition={menu}
|
triggerPosition={menu}
|
||||||
items={menu == null ? [] : menu.column == null ? pickerItems : columnItems(menu.column)}
|
items={menu == null ? [] : menu.column == null ? pickerItems : columnItems(menu.column)}
|
||||||
@@ -245,3 +285,219 @@ export const FlowListHeader = memo(function FlowListHeader({
|
|||||||
</div>
|
</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 {
|
import {
|
||||||
addCustomColumn,
|
addCustomColumn,
|
||||||
addSortKey,
|
addSortKey,
|
||||||
|
allColumns,
|
||||||
cellFilterTerm,
|
cellFilterTerm,
|
||||||
type ColumnPrefs,
|
type ColumnPrefs,
|
||||||
customPath,
|
customPath,
|
||||||
@@ -15,10 +16,13 @@ import {
|
|||||||
gridTemplate,
|
gridTemplate,
|
||||||
gridWidth,
|
gridWidth,
|
||||||
hideOtherColumns,
|
hideOtherColumns,
|
||||||
|
moveColumn,
|
||||||
|
moveColumnBy,
|
||||||
nextSort,
|
nextSort,
|
||||||
operationOf,
|
operationOf,
|
||||||
parseColumnPrefs,
|
parseColumnPrefs,
|
||||||
removeCustomColumn,
|
removeCustomColumn,
|
||||||
|
resetOrder,
|
||||||
resetWidth,
|
resetWidth,
|
||||||
resetWidths,
|
resetWidths,
|
||||||
resizeColumn,
|
resizeColumn,
|
||||||
@@ -121,6 +125,7 @@ describe("column preferences", () => {
|
|||||||
visible: { number: false, "field:header.x-cache": true },
|
visible: { number: false, "field:header.x-cache": true },
|
||||||
widths: { host: 60 },
|
widths: { host: 60 },
|
||||||
custom: ["field:header.x-cache"],
|
custom: ["field:header.x-cache"],
|
||||||
|
order: [],
|
||||||
sort: [{ column: "status", descending: true }],
|
sort: [{ column: "status", descending: true }],
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
@@ -138,6 +143,7 @@ describe("column preferences", () => {
|
|||||||
visible: {},
|
visible: {},
|
||||||
widths: { size: 90 },
|
widths: { size: 90 },
|
||||||
custom: ["field:query.page"],
|
custom: ["field:query.page"],
|
||||||
|
order: [],
|
||||||
sort: [{ column: "time", descending: false }],
|
sort: [{ column: "time", descending: false }],
|
||||||
});
|
});
|
||||||
expect(parseColumnPrefs(null)).toEqual(defaultColumnPrefs);
|
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", () => {
|
describe("sorting", () => {
|
||||||
const one = (column: FlowSortKey["column"], descending: boolean) => [{ column, descending }];
|
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
|
* 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 {
|
export interface ColumnPrefs {
|
||||||
visible: Partial<Record<ColumnId, boolean>>;
|
visible: Partial<Record<ColumnId, boolean>>;
|
||||||
widths: Partial<Record<ColumnId, number>>;
|
widths: Partial<Record<ColumnId, number>>;
|
||||||
custom: CustomColumnId[];
|
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 */
|
/** The keys in order; empty for the order flows arrive in */
|
||||||
sort: FlowSortKey[];
|
sort: FlowSortKey[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultColumnPrefs: ColumnPrefs = { visible: {}, widths: {}, custom: [], sort: [] };
|
export const defaultColumnPrefs: ColumnPrefs = {
|
||||||
|
visible: {},
|
||||||
|
widths: {},
|
||||||
|
custom: [],
|
||||||
|
order: [],
|
||||||
|
sort: [],
|
||||||
|
};
|
||||||
|
|
||||||
export interface Column extends ColumnDef {
|
export interface Column extends ColumnDef {
|
||||||
/** Its width now, in pixels of content */
|
/** Its width now, in pixels of content */
|
||||||
current: number;
|
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[] {
|
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 {
|
function isVisible(prefs: ColumnPrefs, def: ColumnDef): boolean {
|
||||||
@@ -217,7 +236,66 @@ export function toggleColumn(
|
|||||||
): ColumnPrefs {
|
): ColumnPrefs {
|
||||||
const shown = isVisible(prefs, columnDef(id));
|
const shown = isVisible(prefs, columnDef(id));
|
||||||
if (shown && !canHide(prefs, id, unavailable)) return prefs;
|
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 */
|
/** Shows `id` alone */
|
||||||
@@ -227,20 +305,24 @@ export function hideOtherColumns(prefs: ColumnPrefs, id: ColumnId): ColumnPrefs
|
|||||||
return { ...prefs, visible };
|
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 {
|
export function addCustomColumn(prefs: ColumnPrefs, path: string): ColumnPrefs {
|
||||||
const id = customColumnId(path);
|
const id = customColumnId(path);
|
||||||
const custom = prefs.custom.includes(id) ? prefs.custom : [...prefs.custom, id];
|
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 {
|
export function removeCustomColumn(prefs: ColumnPrefs, id: CustomColumnId): ColumnPrefs {
|
||||||
const { [id]: _v, ...visible } = prefs.visible;
|
const { [id]: _v, ...visible } = prefs.visible;
|
||||||
const { [id]: _w, ...widths } = prefs.widths;
|
const { [id]: _w, ...widths } = prefs.widths;
|
||||||
const custom = prefs.custom.filter((c) => c !== id);
|
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);
|
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 {
|
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.
|
* wrong type, so a change to the columns never breaks the list.
|
||||||
*/
|
*/
|
||||||
export function parseColumnPrefs(value: unknown): ColumnPrefs {
|
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;
|
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)) {
|
if (Array.isArray(custom)) {
|
||||||
for (const id of custom) {
|
for (const id of custom) {
|
||||||
if (typeof id !== "string" || !id.startsWith("field:")) continue;
|
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];
|
const w = (widths as Record<string, unknown> | undefined)?.[def.id];
|
||||||
if (typeof w === "number" && Number.isFinite(w) && w > 0) prefs.widths[def.id] = w;
|
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)) {
|
if (Array.isArray(sort)) {
|
||||||
for (const key of sort) {
|
for (const key of sort) {
|
||||||
if (key == null || typeof key !== "object") continue;
|
if (key == null || typeof key !== "object") continue;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@tanstack/react-query": "^5.105.0",
|
"@tanstack/react-query": "^5.105.0",
|
||||||
"@tanstack/react-virtual": "^3.14.24",
|
"@tanstack/react-virtual": "^3.14.24",
|
||||||
"@tauri-apps/api": "^2.12.4",
|
"@tauri-apps/api": "^2.12.4",
|
||||||
|
|||||||
Generated
+1
@@ -246,6 +246,7 @@
|
|||||||
"name": "@yaakapp/yaak-proxy",
|
"name": "@yaakapp/yaak-proxy",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@tanstack/react-query": "^5.105.0",
|
"@tanstack/react-query": "^5.105.0",
|
||||||
"@tanstack/react-virtual": "^3.14.24",
|
"@tanstack/react-virtual": "^3.14.24",
|
||||||
"@tauri-apps/api": "^2.12.4",
|
"@tauri-apps/api": "^2.12.4",
|
||||||
|
|||||||
Reference in new issue
Block a user