diff --git a/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx b/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx index 47bfcdfe1..721af4fa3 100644 --- a/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx +++ b/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx @@ -65,7 +65,7 @@ describe("", () => { annotationHierarchy: [fooAnnotation.name, barAnnotation.name], columns: [...baseDisplayAnnotations, fooAnnotation, barAnnotation].map((a) => ({ name: a.name, - width: 0.1, + width: 100, })), shouldShowNullGroups: false, }, @@ -368,7 +368,7 @@ describe("", () => { annotationHierarchy: [fooAnnotation.name], columns: [...baseDisplayAnnotations, fooAnnotation, barAnnotation].map((a) => ({ name: a.name, - width: 0.1, + width: 100, })), }, }); diff --git a/packages/core/components/FileList/ColumnPicker.tsx b/packages/core/components/FileList/ColumnPicker.tsx index e883478e3..10585cbb8 100644 --- a/packages/core/components/FileList/ColumnPicker.tsx +++ b/packages/core/components/FileList/ColumnPicker.tsx @@ -24,7 +24,7 @@ export default function ColumnPicker() { if (!columnNames.includes(selectedColumn)) { adjustedColumns.push({ name: selectedColumn, - width: 0.25, // Default width of 25% + width: 150, // Default width in px }); } }); diff --git a/packages/core/components/FileList/Header.module.css b/packages/core/components/FileList/Header.module.css index f7634a641..dcabfab83 100644 --- a/packages/core/components/FileList/Header.module.css +++ b/packages/core/components/FileList/Header.module.css @@ -34,12 +34,20 @@ } .header-click-target { + display: flex; + flex-direction: row; + max-height: 21.5px; + min-width: 0; overflow: hidden; +} + +.header-tooltip-wrapper { min-width: 0; + overflow: hidden; } .header-title { - display: inline-block; + display: block; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; @@ -86,7 +94,7 @@ .sort-icon { font-size: 12px; font-weight: bold; - margin-left: 0.5em; + margin: auto 0.25em; } .list-parent { diff --git a/packages/core/components/FileList/Header.tsx b/packages/core/components/FileList/Header.tsx index c117bd24b..e1a5ce1e2 100644 --- a/packages/core/components/FileList/Header.tsx +++ b/packages/core/components/FileList/Header.tsx @@ -4,15 +4,14 @@ import { map } from "lodash"; import * as React from "react"; import { useSelector, useDispatch } from "react-redux"; -import ColumnPicker from "./ColumnPicker"; import useDragAndDropOrder from "./useDragAndDropOrder"; +import useVisibleColumns from "./useVisibleCells"; import { ContextMenuItem } from "../ContextMenu"; import Tooltip from "../Tooltip"; import FileRow, { CellConfig } from "../../components/FileRow"; import { SortOrder } from "../../entity/FileSort"; import Tutorial from "../../entity/Tutorial"; import { interaction, metadata, selection } from "../../state"; -import { Column } from "../../state/selection/actions"; import styles from "./Header.module.css"; @@ -31,18 +30,15 @@ function Header( const annotationNameToAnnotationMap = useSelector( metadata.selectors.getAnnotationNameToAnnotationMap ); - const columns = useSelector(selection.selectors.getColumns); - const columnNames = useSelector(selection.selectors.getColumnNames); + const { columns: visibleColumns, padding } = useVisibleColumns(); + const allColumnNames = useSelector(selection.selectors.getColumnNames); const sortColumn = useSelector(selection.selectors.getSortColumn); const onReorder = React.useCallback( - (newOrder: string[]) => { - const reorderedColumns = newOrder.flatMap( - (name) => columns.find((c) => c.name === name) || [] - ); - dispatch(selection.actions.setColumns(reorderedColumns)); + (item: string, moveTo: number) => { + dispatch(selection.actions.reorderColumns([{ name: item, moveTo }])); }, - [columns, dispatch] + [dispatch] ); const { @@ -52,22 +48,47 @@ function Header( onDragOver, onDrop, onDragEnd, - } = useDragAndDropOrder(columnNames, onReorder); + } = useDragAndDropOrder(allColumnNames, onReorder); const onResize = (name: string, width?: number) => { - // Default to 0.25 if width is undefined - // which resets the column width to the default - dispatch(selection.actions.resizeColumn({ name, width: width || 0.25 })); + dispatch(selection.actions.resizeColumn({ name, width })); }; - const onHeaderColumnClick = (evt: React.MouseEvent, column: Column) => { + const onHeaderNameClick = (evt: React.MouseEvent, columnName: string) => { // Prevent this click from bubbling up to the header's onClick // which opens the column picker context menu evt.stopPropagation(); - dispatch(selection.actions.sortColumn(column.name)); + dispatch(selection.actions.sortColumn(columnName)); }; - const headerCells: CellConfig[] = map(columns, (column) => ({ + const onHeaderColumnClick = (evt: React.MouseEvent, columnName: string) => { + evt.preventDefault(); + const items: ContextMenuItem[] = [ + { + key: "Move to start", + text: "Move to start", + title: "Move column to the start", + onClick: () => { + dispatch(selection.actions.reorderColumns([{ name: columnName, moveTo: 0 }])); + }, + }, + { + key: "Move to end", + text: "Move to end", + title: "Move column to the end", + onClick: () => { + dispatch( + selection.actions.reorderColumns([ + { name: columnName, moveTo: allColumnNames.length - 1 }, + ]) + ); + }, + }, + ]; + dispatch(interaction.actions.showContextMenu(items, evt.nativeEvent)); + }; + + const headerCells: CellConfig[] = map(visibleColumns, (column) => ({ className: classNames(styles.headerCell, { [styles.dragOver]: dragOverItem === column.name && draggedItem !== column.name, [styles.dragging]: draggedItem === column.name, @@ -87,60 +108,39 @@ function Header( onDragOver={(e) => onDragOver(e, column.name)} onDrop={() => onDrop(column.name)} onDragEnd={onDragEnd} + onClick={(evt) => onHeaderColumnClick(evt, column.name)} > - onHeaderColumnClick(evt, column)} +
onHeaderNameClick(evt, column.name)} className={styles.headerClickTarget} > - - - {annotationNameToAnnotationMap[column.name]?.displayName} - - + + + + {annotationNameToAnnotationMap[column.name]?.displayName} + + + {sortColumn?.annotationName === column.name && (sortColumn?.order === SortOrder.DESC ? ( ) : ( ))} - +
), width: column.width, })); - const onHeaderClick = (evt: React.MouseEvent) => { - evt.preventDefault(); - const items: ContextMenuItem[] = [ - { - key: "modify-columns", - text: "Modify columns", - title: "Modify columns displayed in the file list", - iconProps: { - iconName: "TripleColumnEdit", - }, - items: [ - { - key: "available-annotations", - text: "Available annotations", - onRender() { - return ; - }, - }, - ], - }, - ]; - dispatch(interaction.actions.showContextMenu(items, evt.nativeEvent)); - }; - return (
{children}
diff --git a/packages/core/components/FileList/HorizontalScrollContext.ts b/packages/core/components/FileList/HorizontalScrollContext.ts new file mode 100644 index 000000000..ade61821c --- /dev/null +++ b/packages/core/components/FileList/HorizontalScrollContext.ts @@ -0,0 +1,17 @@ +import * as React from "react"; + +interface HorizontalScrollState { + scrollLeft: number; + containerWidth: number; +} + +/** + * Context providing horizontal scroll position and visible width of the file list's + * scroll container. Used by FileRow to virtualize cells horizontally. + */ +const HorizontalScrollContext = React.createContext({ + scrollLeft: 0, + containerWidth: 0, +}); + +export default HorizontalScrollContext; diff --git a/packages/core/components/FileList/LazilyRenderedRow.tsx b/packages/core/components/FileList/LazilyRenderedRow.tsx index d494f439c..ed2f0005f 100644 --- a/packages/core/components/FileList/LazilyRenderedRow.tsx +++ b/packages/core/components/FileList/LazilyRenderedRow.tsx @@ -4,10 +4,11 @@ import { map } from "lodash"; import * as React from "react"; import { useSelector } from "react-redux"; +import { OnSelect } from "./useFileSelector"; +import useVisibleColumns from "./useVisibleCells"; import FileRow from "../../components/FileRow"; import FileSet from "../../entity/FileSet"; import { metadata, selection } from "../../state"; -import { OnSelect } from "./useFileSelector"; import styles from "./LazilyRenderedRow.module.css"; @@ -27,8 +28,6 @@ interface LazilyRenderedRowProps { style: React.CSSProperties; // injected by react-window } -const MARGIN = 1.5; // px; defined in LazilyRenderedRow.module.css - /** * A single file in the listing of available files FMS. */ @@ -39,7 +38,8 @@ export default function LazilyRenderedRow(props: LazilyRenderedRowProps) { style, } = props; - const columns = useSelector(selection.selectors.getColumns); + const { columns: visibleColumns, padding } = useVisibleColumns(); + const totalColumnWidth = useSelector(selection.selectors.getTotalColumnWidth); const isSmallFont = useSelector(selection.selectors.getShouldDisplaySmallFont); const annotationNameToAnnotationMap = useSelector( metadata.selectors.getAnnotationNameToAnnotationMap @@ -64,13 +64,14 @@ export default function LazilyRenderedRow(props: LazilyRenderedRowProps) { if (file) { content = ( ({ + cells={map(visibleColumns, (column) => ({ columnKey: column.name, displayValue: annotationNameToAnnotationMap[column.name]?.extractFromFile(file), width: column.width, }))} rowIdentifier={{ index, id: file.uid }} onSelect={onSelect} + padding={padding} /> ); } else { @@ -95,7 +96,7 @@ export default function LazilyRenderedRow(props: LazilyRenderedRowProps) { })} style={{ ...style, - width: `calc(100% - ${2 * MARGIN}px)`, + width: `${totalColumnWidth}px`, }} onContextMenu={onContextMenu} > diff --git a/packages/core/components/FileList/index.tsx b/packages/core/components/FileList/index.tsx index 71cae8d3c..3b0a721f9 100644 --- a/packages/core/components/FileList/index.tsx +++ b/packages/core/components/FileList/index.tsx @@ -7,6 +7,7 @@ import { FixedSizeGrid, FixedSizeList } from "react-window"; import InfiniteLoader from "react-window-infinite-loader"; import Header from "./Header"; +import HorizontalScrollContext from "./HorizontalScrollContext"; import LazilyRenderedRow from "./LazilyRenderedRow"; import LazilyRenderedThumbnail from "./LazilyRenderedThumbnail"; import useFileSelector from "./useFileSelector"; @@ -56,7 +57,7 @@ export default function FileList(props: FileListProps) { const fileSelection = useSelector(selection.selectors.getFileSelection); const fileGridColumnCount = useSelector(selection.selectors.getFileGridColCount); const isDisplayingSmallFont = useSelector(selection.selectors.getShouldDisplaySmallFont); - const isColumnWidthOverflowing = useSelector(selection.selectors.isColumnWidthOverflowing); + const totalColumnWidth = useSelector(selection.selectors.getTotalColumnWidth); const areAnnotationsLoaded = useSelector(metadata.selectors.areAnnotationsLoaded); const [measuredNodeRef, measuredHeight, measuredWidth] = useLayoutMeasurements< HTMLDivElement @@ -83,15 +84,71 @@ export default function FileList(props: FileListProps) { const totalRows = Math.ceil( (totalCount || DEFAULT_TOTAL_COUNT) / (fileView === FileView.LIST ? 1 : fileGridColumnCount) ); + // Whether the total column width exceeds the visible container width + const isColumnWidthOverflowing = fileView === FileView.LIST && totalColumnWidth > measuredWidth; // complement to isColumnWidthOverflowing const isRowHeightOverflowing = totalRows * rowHeight > height; // hide overlay when we reach the bottom of the list const atEndOfList = lastVisibleRowIndex === totalRows - 1; + // Track horizontal scroll position for cell virtualization + const [horizontalScroll, setHorizontalScroll] = React.useState({ + scrollLeft: 0, + containerWidth: 0, + }); + const listRef = React.useRef(null); const gridRef = React.useRef(null); const outerRef = React.useRef(null); + // These refs support the scroll-tracking effect below without causing extra renders. + // lastScrollLeftRef lets us ignore scroll events that are purely vertical (react-window + // fires one scroll event for both axes, but we only care about horizontal changes). + // rafIdRef lets us coalesce rapid scroll events into a single state update per animation frame. + const lastScrollLeftRef = React.useRef(0); + const rafIdRef = React.useRef(0); + // Tracks horizontal scroll position and container width for HorizontalScrollContext, which + // drives column-level virtualisation in useVisibleCells. Initialises containerWidth on mount + // (it starts at 0, which causes the hook to fall back to rendering the first 6 columns). + // Re-attaches the listener when totalCount changes because react-window may remount outerRef. + React.useEffect(() => { + const el = outerRef.current; + if (!el) return; + // Initialize container width + setHorizontalScroll({ scrollLeft: el.scrollLeft, containerWidth: el.clientWidth }); + lastScrollLeftRef.current = el.scrollLeft; + + const onScroll = () => { + // Only update state when horizontal scroll position changes + if (el.scrollLeft === lastScrollLeftRef.current) return; + lastScrollLeftRef.current = el.scrollLeft; + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = requestAnimationFrame(() => { + setHorizontalScroll({ scrollLeft: el.scrollLeft, containerWidth: el.clientWidth }); + }); + }; + el.addEventListener("scroll", onScroll, { passive: true }); + return () => { + el.removeEventListener("scroll", onScroll); + cancelAnimationFrame(rafIdRef.current); + }; + // Re-attach listener when totalCount changes, as react-window may remount the outerRef div in that case + // but also when the fileView changes so that the scroll position can be reset + // and when measuredWidth changes to ensure containerWidth is accurate for useVisibleCells (like if window size changes) + }, [fileSet.instanceId, fileView, measuredWidth, totalCount]); + + // Restore horizontal scroll position after InfiniteLoader remounts (e.g., sort changes). + // The key change unmounts the scrollable container, creating a new one with scrollLeft=0. + // lastScrollLeftRef still holds the previous position since FileList itself isn't remounted. + React.useLayoutEffect(() => { + const el = outerRef.current; + if (!el) return; + const target = lastScrollLeftRef.current; + if (target > 0 && Math.abs(el.scrollLeft - target) > 1) { + el.scrollLeft = target; + } + }, [fileSet.instanceId, fileView]); + // This hook is responsible for ensuring that if the details pane is currently showing a file row // within this FileList the file row shown in the details pane is scrolled into view. React.useEffect(() => { @@ -180,6 +237,16 @@ export default function FileList(props: FileListProps) { [fileFetchWrapper] ); + // Memoize itemData to prevent FixedSizeList from re-rendering all rows on unrelated state changes + const listItemData = React.useMemo( + () => ({ + fileSet: fileSet, + onSelect, + onContextMenu: onFileRowContextMenu, + }), + [fileSet, onSelect, onFileRowContextMenu] + ); + let content: React.ReactNode; if (!!localError) { return ( @@ -255,11 +322,7 @@ export default function FileList(props: FileListProps) { if (fileView === FileView.LIST) { return ( -
-
+ +
- {content} + className={classNames(styles.list)} + style={{ + height: isRoot ? undefined : `${calculatedHeight}px`, + }} + ref={measuredNodeRef} + > +
+
+ {content} +
+

+ {totalCount !== null + ? `${totalCount.toLocaleString()} files` + : "Counting files..."} +

-

- {totalCount !== null ? `${totalCount.toLocaleString()} files` : "Counting files..."} -

-
+ ); } diff --git a/packages/core/components/FileList/test/Header.test.tsx b/packages/core/components/FileList/test/Header.test.tsx index fb2faeb4e..a6535fbeb 100644 --- a/packages/core/components/FileList/test/Header.test.tsx +++ b/packages/core/components/FileList/test/Header.test.tsx @@ -127,7 +127,7 @@ describe("
", () => { expect(fileSizeCell).to.exist; }); - it("dispatches setColumns with reordered columns when column is dragged to new position", () => { + it("dispatches reorderColumns with reordered columns when column is dragged to new position", () => { // Arrange const annotations = [ AnnotationName.FILE_NAME, @@ -168,12 +168,10 @@ describe("
", () => { fireEvent.drop(fileNameCell); // Assert: FILE_SIZE should be moved to index 0, rest shift right - const expectedColumns = [ - { name: AnnotationName.FILE_SIZE, width: 1 / annotations.length }, - { name: AnnotationName.FILE_NAME, width: 1 / annotations.length }, - { name: AnnotationName.KIND, width: 1 / annotations.length }, - { name: AnnotationName.UPLOADED, width: 1 / annotations.length }, - ]; - expect(actions.includesMatch(selection.actions.setColumns(expectedColumns))).to.be.true; + expect( + actions.includesMatch( + selection.actions.reorderColumns([{ name: AnnotationName.FILE_SIZE, moveTo: 0 }]) + ) + ).to.be.true; }); }); diff --git a/packages/core/components/FileList/test/LazilyRenderedRow.test.tsx b/packages/core/components/FileList/test/LazilyRenderedRow.test.tsx index e4b823dc6..02ba37ab3 100644 --- a/packages/core/components/FileList/test/LazilyRenderedRow.test.tsx +++ b/packages/core/components/FileList/test/LazilyRenderedRow.test.tsx @@ -57,7 +57,7 @@ describe("", () => { // Arrange const state = mergeState(initialState, {}); state.metadata.annotations = [fileNameAnnotation]; - state.selection.columns = [{ name: fileNameAnnotation.name, width: 0.25 }]; + state.selection.columns = [{ name: fileNameAnnotation.name, width: 200 }]; const { store } = configureMockStore({ state }); diff --git a/packages/core/components/FileList/test/useDragAndDropOrder.test.tsx b/packages/core/components/FileList/test/useDragAndDropOrder.test.tsx index f46531c66..d072f3644 100644 --- a/packages/core/components/FileList/test/useDragAndDropOrder.test.tsx +++ b/packages/core/components/FileList/test/useDragAndDropOrder.test.tsx @@ -15,7 +15,7 @@ function DraggableList({ onReorder, }: { items: string[]; - onReorder: (newOrder: string[]) => void; + onReorder: (item: string, moveTo: number) => void; }) { const { draggedItem, @@ -62,7 +62,7 @@ describe("useDragAndDropOrder", () => { fireEvent.dragOver(getByTestId("a")); fireEvent.drop(getByTestId("a")); - expect(onReorder.calledOnceWith(["c", "a", "b"])).to.be.true; + expect(onReorder.calledOnceWith("c", 0)).to.be.true; }); it("does not call onReorder when an item is dropped onto itself", () => { diff --git a/packages/core/components/FileList/test/useVisibleColumns.test.tsx b/packages/core/components/FileList/test/useVisibleColumns.test.tsx new file mode 100644 index 000000000..07c251cb6 --- /dev/null +++ b/packages/core/components/FileList/test/useVisibleColumns.test.tsx @@ -0,0 +1,117 @@ +import { configureMockStore, mergeState } from "@aics/redux-utils"; +import { render } from "@testing-library/react"; +import { expect } from "chai"; +import * as React from "react"; +import { Provider } from "react-redux"; + +import useVisibleColumns from "../useVisibleCells"; +import HorizontalScrollContext from "../HorizontalScrollContext"; +import { initialState } from "../../../state"; +import { Column } from "../../../state/selection/actions"; + +/** + * Test harness that renders the hook's output as data attributes for assertion. + */ +function MockHookedComponent() { + const { columns, padding } = useVisibleColumns(); + return ( +
c.name))} + data-left={padding.left} + data-right={padding.right} + /> + ); +} + +function renderWithContext(columns: Column[], scrollLeft: number, containerWidth: number) { + const state = mergeState(initialState, { + selection: { columns }, + }); + const { store } = configureMockStore({ state }); + + const { getByTestId } = render( + + + + + + ); + + const el = getByTestId("result"); + return { + columns: JSON.parse(el.getAttribute("data-columns") || "[]") as string[], + left: Number(el.getAttribute("data-left")), + right: Number(el.getAttribute("data-right")), + }; +} + +describe("useVisibleColumns", () => { + const columns: Column[] = [ + { name: "A", width: 200 }, + { name: "B", width: 3000 }, + { name: "C", width: 200 }, + { name: "D", width: 100 }, + { name: "E", width: 200 }, + { name: "F", width: 200 }, + { name: "G", width: 2 }, + { name: "H", width: 200 }, + { name: "I", width: 200 }, + { name: "J", width: 2000 }, + ]; + + it("returns first N columns when containerWidth is 0 (not yet measured)", () => { + const result = renderWithContext(columns, 0, 0); + // Hook returns up to 6 columns when containerWidth is 0 + expect(result.columns).to.deep.equal(["A", "B", "C", "D", "E", "F"]); + expect(result.left).to.equal(0); + expect(result.right).to.equal(0); + }); + + it("returns only visible columns for a given scroll position", () => { + // containerWidth=400 at scrollLeft=0 → viewStart=-200, viewEnd=600 + // A(0-200) visible, B(200-3200) extends past viewEnd so endIndex=2 + const result = renderWithContext(columns, 0, 400); + expect(result.columns).to.deep.equal(["A", "B"]); + expect(result.left).to.equal(0); + // C(200)+D(100)+E(200)+F(200)+G(2)+H(200)+I(200)+J(2000) = 3102 + expect(result.right).to.equal(3102); + }); + + it("calculates correct left and right padding when scrolled to the middle", () => { + // scrollLeft=400, containerWidth=400 → viewStart=200, viewEnd=1000 + // A(0-200): 200 > 200? No → skip. B(200-3200): 3200 > 200 → startIndex=1, leftPad=200 + // B is so wide (3000px) that cumulative reaches 3200 >= 1000 → endIndex=2 + const result = renderWithContext(columns, 400, 400); + expect(result.left).to.equal(200); + expect(result.columns).to.deep.equal(["B"]); + // C through J = 3102 + expect(result.right).to.equal(3102); + }); + + it("returns all columns when total width fits within containerWidth + overscan", () => { + // Total width = 6302. containerWidth must be large enough for viewEnd to exceed it. + // containerWidth=6302 → viewEnd = 0 + 6302 + 200 = 6502 > 6302 → all visible + const result = renderWithContext(columns, 0, 6302); + expect(result.columns).to.deep.equal(["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"]); + expect(result.left).to.equal(0); + expect(result.right).to.equal(0); + }); + + it("handles columns with uneven widths", () => { + const unevenColumns: Column[] = [ + { name: "narrow", width: 50 }, + { name: "wide", width: 500 }, + { name: "medium", width: 150 }, + { name: "large", width: 400 }, + ]; + // scrollLeft=100, containerWidth=300 → viewStart=-100, viewEnd=600 + // narrow(0-50): 50 > -100 → visible, leftPad=0 + // wide(50-550): cumulative=550, 550>=600? No + // medium(550-700): cumulative=700, 700>=600? Yes → endIndex=3 + const result = renderWithContext(unevenColumns, 100, 300); + expect(result.columns).to.deep.equal(["narrow", "wide", "medium"]); + expect(result.left).to.equal(0); + expect(result.right).to.equal(unevenColumns.find((c) => c.name === "large")?.width); + }); +}); diff --git a/packages/core/components/FileList/useDragAndDropOrder.ts b/packages/core/components/FileList/useDragAndDropOrder.ts index 9d090cb86..7c3283fec 100644 --- a/packages/core/components/FileList/useDragAndDropOrder.ts +++ b/packages/core/components/FileList/useDragAndDropOrder.ts @@ -11,7 +11,7 @@ import * as React from "react"; */ export default function useDragAndDropOrder( items: string[], - onReorder: (newOrder: string[]) => void + onReorder: (item: string, moveTo: number) => void ) { const [draggedItem, setDraggedItem] = React.useState(null); const [dragOverItem, setDragOverItem] = React.useState(null); @@ -35,12 +35,8 @@ export default function useDragAndDropOrder( setDragOverItem(null); return; } - const reordered = [...items]; - const fromIndex = reordered.indexOf(draggedItem); - const toIndex = reordered.indexOf(targetKey); - const [removed] = reordered.splice(fromIndex, 1); - reordered.splice(toIndex, 0, removed); - onReorder(reordered); + const toIndex = items.indexOf(targetKey); + onReorder(draggedItem, toIndex); setDraggedItem(null); setDragOverItem(null); }, diff --git a/packages/core/components/FileList/useVisibleCells.ts b/packages/core/components/FileList/useVisibleCells.ts new file mode 100644 index 000000000..7d1818e15 --- /dev/null +++ b/packages/core/components/FileList/useVisibleCells.ts @@ -0,0 +1,73 @@ +import * as React from "react"; +import { useSelector } from "react-redux"; + +import HorizontalScrollContext from "./HorizontalScrollContext"; +import { selection } from "../../state"; + +// Number of pixels to render beyond the visible area on each side +const OVERSCAN = 200; + +/** + * Custom hook to determine which columns in a FileRow should be rendered based on + * the current horizontal scroll position and container width. + * + * Returns an object containing the visible `columns` to render and `padding` + * values for the left and right spacer widths needed to offset those columns + * within the FileRow. This is used to implement virtualized horizontal + * scrolling within FileRow so large column sets can be rendered efficiently. + */ +export default function useVisibleColumns() { + const columns = useSelector(selection.selectors.getColumns); + const { scrollLeft, containerWidth } = React.useContext(HorizontalScrollContext); + + // Determine visible range of columns based on horizontal scroll position. + // Use a spacer before and after for off-screen columns to preserve correct layout. + return React.useMemo(() => { + // If no container width yet (not measured), render some columns + if (!containerWidth) { + return { + columns: columns.slice(0, 6), // Arbitrary limit to prevent rendering too many columns before measurement + padding: { left: 0, right: 0 }, + }; + } + + const viewStart = scrollLeft - OVERSCAN; + const viewEnd = scrollLeft + containerWidth + OVERSCAN; + + let cumulativeLeft = 0; + let startIndex = 0; + let endIndex = columns.length; + let leftPad = 0; + let rightPad = 0; + + // Find first visible column + for (let i = 0; i < columns.length; i++) { + if (cumulativeLeft + columns[i].width > viewStart) { + startIndex = i; + leftPad = cumulativeLeft; + break; + } + cumulativeLeft += columns[i].width; + } + + // Find last visible column + cumulativeLeft = leftPad; + for (let i = startIndex; i < columns.length; i++) { + cumulativeLeft += columns[i].width; + if (cumulativeLeft >= viewEnd) { + endIndex = i + 1; + break; + } + } + + // Calculate right padding + for (let i = endIndex; i < columns.length; i++) { + rightPad += columns[i].width; + } + + return { + columns: columns.slice(startIndex, endIndex), + padding: { left: leftPad, right: rightPad }, + }; + }, [columns, scrollLeft, containerWidth]); +} diff --git a/packages/core/components/FileRow/Cell.module.css b/packages/core/components/FileRow/Cell.module.css index 10f5af4ca..43c6d9502 100644 --- a/packages/core/components/FileRow/Cell.module.css +++ b/packages/core/components/FileRow/Cell.module.css @@ -1,5 +1,6 @@ .cell { --padding: 16px; + --resize-target-offset: 8px; position: relative; display: inline-block; @@ -14,6 +15,7 @@ background-color: inherit; } + .cell:first-of-type { padding-left: calc(var(--padding) / 2); } @@ -25,7 +27,7 @@ .cell-content { overflow: hidden; /* Leave room for the resize handle */ - width: calc(100% - 8px); + width: calc(100% - var(--resize-target-offset)); } .cursor-resize-either-direction { @@ -41,7 +43,7 @@ font-size: 18px; font-weight: bolder; position: absolute; - right: calc(1em / 2); + right: var(--resize-target-offset); text-align: center; top: 3px; touch-action: none; diff --git a/packages/core/components/FileRow/Cell.tsx b/packages/core/components/FileRow/Cell.tsx index cb9ca8835..3fec1b6b6 100644 --- a/packages/core/components/FileRow/Cell.tsx +++ b/packages/core/components/FileRow/Cell.tsx @@ -14,14 +14,14 @@ export interface CellProps { className?: string; columnKey: string; onContextMenu?: (evt: React.MouseEvent) => void; - onResize?: (columnKey: string, nextWidth?: number) => void; // nextWith is a percentage of parent element's width, a number between 0 and 1. + onResize?: (columnKey: string, nextWidth?: number) => void; // nextWidth is in pixels title?: string; - width: number; // percentage of parent element's width, a number between 0 and 1. + width: number; // width in pixels } interface CellState { containerClassName?: string; - provisionalWidth?: number; // a percentage of parent element's width, a number between 0 and 1 + provisionalWidth?: number; // width in pixels resizeTargetClassName: string; } @@ -35,9 +35,7 @@ enum ResizeDirection { * not resizable--this is determined by whether `props.onResize` is provided. If the cell is resizable, a user can reset * the width to its default by double clicking the cell. * - * This component deals in percentage widths to avoid requiring components that make use of this to measure themselves; e.g. - * it enables a configuration of, "each cell should take up 25% of the total width," without having to resolve that - * within pixel space. + * This component uses pixel-based widths for columns. */ export default class Cell extends React.Component, CellState> { public static MINIMUM_WIDTH = 50; // px; somewhat arbitrary @@ -102,7 +100,7 @@ export default class Cell extends React.Component @@ -127,7 +125,7 @@ export default class Cell extends React.Component @@ -148,22 +146,6 @@ export default class Cell extends React.Component void; onResize?: (columnKey: string, nextWidth?: number) => void; onSelect?: OnSelect; + // Used by Header and Row to add padding for overscanning purposes. + // The padding is added to the left and right of the row, and is + // meant to be large enough to accommodate the scrollbar width as well as some buffer. + padding?: { left: number; right: number }; } /** @@ -51,7 +55,11 @@ export default function FileRow(props: FileRowProps) { }; return ( -
+
{map(cells, (cell) => ( `${column.name}${ColumnCoder.VALUE_DELIMETER}${column.width}`) - .join(ColumnCoder.COLUMN_DELIMETER); + return ( + columns + // Encode width as divided by COLUMN_VALUE_PRECISION to shorten the resulting URL; + // this is an arbitrary choice to balance URL length with precision of column widths + .map( + (column) => + `${column.name}${ColumnCoder.VALUE_DELIMETER}${Math.ceil( + column.width / ColumnCoder.COLUMN_VALUE_PRECISION + )}` + ) + // Arbitrary limit to prevent URLs from getting too long; + // if users have more than 6 columns they can resize and reorder them in-app after loading the URL + .slice(0, 6) + .join(ColumnCoder.COLUMN_DELIMETER) + ); } public static decode(encoded: string): Column[] { @@ -110,7 +128,19 @@ class ColumnCoder { .filter((unparsedColumn) => !!unparsedColumn) .map((unparsedColumn) => { const [name, widthAsStr] = unparsedColumn.split(ColumnCoder.VALUE_DELIMETER); - return { name, width: parseFloat(widthAsStr) }; + const parsedWidth = parseFloat(widthAsStr); + // The column width was previously encoded as a number between 0 and 1 representing the percentage of available + // space the column should take up, but this was difficult to work with and unintuitive for users. + // Now we encode the actual pixel width, which is more straightforward to understand and work with when manually editing URLs. + // To maintain backwards compatibility with existing URLs, we continue to support previously encoded widths as percentages, + // but we default them to a default column width in pixels in the decoding process. + // Also, multiply the parsedWidth by COLUMN_VALUE_PRECISION because it is encoded as the actual width divided by COLUMN_VALUE_PRECISION to + // shorten the resulting URL; this is an arbitrary choice to balance URL length with precision of column widths. + const width = + parsedWidth <= 1 + ? DEFAULT_COLUMN_WIDTH + : parsedWidth * ColumnCoder.COLUMN_VALUE_PRECISION; + return { name, width }; }); } } @@ -254,9 +284,7 @@ export default class SearchParams { .filter((parsedFolder) => parsedFolder.length <= hierarchyDepth) .map((parsedFolder) => new FileFolder(parsedFolder)), prov: unparsedSourceProvenance ? JSON.parse(unparsedSourceProvenance) : undefined, - showNoValueGroups: showNoValueGroupsString - ? JSON.parse(showNoValueGroupsString) - : true, + showNoValueGroups: showNoValueGroupsString ? JSON.parse(showNoValueGroupsString) : true, sortColumn: parsedSort ? new FileSort(parsedSort.annotationName, parsedSort.order || SortOrder.ASC) : undefined, diff --git a/packages/core/state/metadata/logics.ts b/packages/core/state/metadata/logics.ts index e3dbc4980..19a44a8d3 100644 --- a/packages/core/state/metadata/logics.ts +++ b/packages/core/state/metadata/logics.ts @@ -26,6 +26,7 @@ import AnnotationName from "../../entity/Annotation/AnnotationName"; import { AnnotationType, AnnotationTypeIdMap } from "../../entity/AnnotationFormatter"; import FileFilter from "../../entity/FileFilter"; import FileSort, { SortOrder } from "../../entity/FileSort"; +import { DEFAULT_COLUMN_WIDTH } from "../../entity/SearchParams"; import HttpAnnotationService from "../../services/AnnotationService/HttpAnnotationService"; /** @@ -63,8 +64,7 @@ const requestAnnotations = createLogic({ */ const receiveAnnotationsLogic = createLogic({ async process(deps: ReduxLogicDeps, dispatch, done) { - const actions = deps.action as ReceiveAnnotationAction; - const annotations = actions.payload; + const { payload: annotations } = deps.action as ReceiveAnnotationAction; const currentSortColumn = selection.selectors.getSortColumn(deps.getState()); const currentColumns = selection.selectors.getColumns(deps.getState()); const isQueryingAicsFms = selection.selectors.isQueryingAicsFms(deps.getState()); @@ -81,25 +81,41 @@ const receiveAnnotationsLogic = createLogic({ ); const columnNamesThatStillExist = columnsThatStillExist.map((column) => column.name); - // Grab the first countOfColumnsToShow annotations as columns based on the following priority: - // 1) Was already a column - // 2) Is just in the data source - const countOfColumnsToShow = Math.max(4, columnsThatStillExist.length); - const remainingMaxWidth = columnsThatStillExist.reduce( - (remainingWidth, column) => remainingWidth - column.width, - 1 + const newAnnotations = annotations.filter( + (annotation) => !columnNamesThatStillExist.includes(annotation.name) ); - const columns = [ + + // TODO: To come in follow-up PR: calculate optimal column widths for new annotations based on content + // (currently defaulting to an arbitrary width for all new columns) + const widthByAnnotation: Record = {}; + // Try to fetch values for new annotations to compute optimal column widths + // const widthByAnnotation = await annotationService.fetchOptimalWidthForAnnotations( + // newAnnotations.map((annotation) => annotation.name) + // ); + + let columns = [ ...columnsThatStillExist, - ...annotations - .filter((annotation) => !columnNamesThatStillExist.includes(annotation.name)) - .slice(0, countOfColumnsToShow - columnsThatStillExist.length) - .map((annotation) => ({ - name: annotation.name, - width: - remainingMaxWidth / (countOfColumnsToShow - columnsThatStillExist.length), - })), + ...newAnnotations.map((annotation) => ({ + name: annotation.name, + // TODO: Remove default when above optimal width fetching is implemented + width: widthByAnnotation[annotation.name] ?? DEFAULT_COLUMN_WIDTH, + })), ]; + + // If there were no columns selected, default to displaying + // "File Name" first for any data source + if (!columnsThatStillExist.length) { + // Remove filename annotations from columns before re-adding it at the front, + columns = columns.filter((column) => column.name !== AnnotationName.FILE_NAME); + + // Add "File Name" back to the front of the columns array + columns.unshift({ + name: AnnotationName.FILE_NAME, + // TODO: Remove default when above optimal width fetching is implemented + width: widthByAnnotation[AnnotationName.FILE_NAME] ?? DEFAULT_COLUMN_WIDTH, + }); + } + dispatch(selection.actions.setColumns(columns)); const isCurrentSortColumnValid = diff --git a/packages/core/state/metadata/test/logics.test.ts b/packages/core/state/metadata/test/logics.test.ts index ffb75b777..10186d5c9 100644 --- a/packages/core/state/metadata/test/logics.test.ts +++ b/packages/core/state/metadata/test/logics.test.ts @@ -143,9 +143,9 @@ describe("Metadata logics", () => { it("only dispatches columns that still exist in the data source", async () => { // arrange const mockColumns = mockAnnotations.map((ann) => { - return { name: ann.name, width: 0.2 }; + return { name: ann.name, width: 200 }; }); - const columnNoLongerExists = { name: "old column", width: 0.2 }; + const columnNoLongerExists = { name: "old column", width: 200 }; const state = mergeState(initialState, { selection: { columns: [...mockColumns, columnNoLongerExists], @@ -171,6 +171,57 @@ describe("Metadata logics", () => { // the call should not include the column that no longer exists expect(matchingAction?.payload.length).to.equal(mockColumns.length); }); + + it("dispatches all annotations as columns when there are no existing columns", async () => { + // arrange: no existing columns + const { store, logicMiddleware, actions } = configureMockStore({ + state: initialState, + logics: metadataLogics, + }); + + // act + store.dispatch(receiveAnnotations(mockAnnotations)); + await logicMiddleware.whenComplete(); + + // assert: all annotations should be shown as columns, plus a "File Name" column prepended + expect(actions.includesMatch({ type: SET_COLUMNS })).to.be.true; + const matchingAction = actions.list + .filter((action) => action.type === SET_COLUMNS) + .at(0); + // 3 mock annotations + 1 prepended "file_name" column + expect(matchingAction?.payload.length).to.equal(mockAnnotations.length + 1); + expect(matchingAction?.payload[0].name).to.equal("file_name"); + }); + + it("adds all new annotations as columns to existing ones", async () => { + // arrange: some existing columns + const existingColumns = [{ name: mockAnnotations[0].name, width: 300 }]; + const state = mergeState(initialState, { + selection: { + columns: existingColumns, + }, + }); + const { store, logicMiddleware, actions } = configureMockStore({ + state, + logics: metadataLogics, + }); + + // act: receive annotations that include one already shown and two new ones + store.dispatch(receiveAnnotations(mockAnnotations)); + await logicMiddleware.whenComplete(); + + // assert: all annotations should be columns (existing kept, new ones added) + expect(actions.includesMatch({ type: SET_COLUMNS })).to.be.true; + const matchingAction = actions.list + .filter((action) => action.type === SET_COLUMNS) + .at(0); + expect(matchingAction?.payload.length).to.equal(mockAnnotations.length); + // existing column should retain its original width + const existingColumn = matchingAction?.payload.find( + (col: { name: string; width: number }) => col.name === mockAnnotations[0].name + ); + expect(existingColumn?.width).to.equal(300); + }); }); describe("requestDataSources", () => { diff --git a/packages/core/state/selection/actions.ts b/packages/core/state/selection/actions.ts index 5c138fa8e..d11805f6d 100644 --- a/packages/core/state/selection/actions.ts +++ b/packages/core/state/selection/actions.ts @@ -140,7 +140,7 @@ export function setSortColumn(fileSort?: FileSort): SetSortColumnAction { export interface Column { name: string; - width: number; // percent between 0 and 1 + width: number; // width in pixels } /** @@ -151,11 +151,14 @@ export interface Column { export const RESIZE_COLUMN = makeConstant(STATE_BRANCH_NAME, "resize-column"); export interface ResizeColumnAction { - payload: Column; + payload: { + name: string; + width?: number; // width in pixels, if not provided, defaults to auto-sizing based on content + }; type: string; } -export function resizeColumn(column: Column) { +export function resizeColumn(column: { name: string; width?: number }): ResizeColumnAction { return { payload: column, type: RESIZE_COLUMN, @@ -181,6 +184,32 @@ export function setColumns(columns: Column[]) { }; } +/** + * REORDER_COLUMN + * + * Intention to move one or more columns to a specific index within the column list + * without needing to supply the full list of columns. + */ +export const REORDER_COLUMNS = makeConstant(STATE_BRANCH_NAME, "reorder-columns"); + +interface ColumnReordersPayload { + name: string; // name of the column to move + moveTo: number; // index to move column to + width?: number; // width in pixels, defaults to existing width if not provided +} + +export interface ReorderColumnsAction { + payload: ColumnReordersPayload[]; + type: string; +} + +export function reorderColumns(columnReorder: ColumnReordersPayload[]): ReorderColumnsAction { + return { + payload: columnReorder, + type: REORDER_COLUMNS, + }; +} + /** * SELECT_FILE * diff --git a/packages/core/state/selection/logics.ts b/packages/core/state/selection/logics.ts index de4b5a1c6..18538e94a 100644 --- a/packages/core/state/selection/logics.ts +++ b/packages/core/state/selection/logics.ts @@ -51,17 +51,20 @@ import { CHANGE_FILE_FILTER_TYPE, AddDataSourceReloadError, setFileView, - setColumns, EXPAND_ALL_FILE_FOLDERS, toggleNullValueGroups, setIsLoadingSource, + RESIZE_COLUMN, + ResizeColumnAction, + setColumns, + Column, } from "./actions"; import { interaction, metadata, ReduxLogicDeps, selection } from "../"; import * as selectionSelectors from "./selectors"; import { findChildNodes } from "../../components/DirectoryTree/findChildNodes"; import { NO_VALUE_NODE, ROOT_NODE } from "../../components/DirectoryTree/directory-hierarchy-state"; import Annotation from "../../entity/Annotation"; -import SearchParams from "../../entity/SearchParams"; +import SearchParams, { DEFAULT_COLUMN_WIDTH } from "../../entity/SearchParams"; import FileFilter, { FilterType } from "../../entity/FileFilter"; import FileFolder from "../../entity/FileFolder"; import FileSelection from "../../entity/FileSelection"; @@ -415,6 +418,39 @@ const expandAllFileFolders = createLogic({ type: [EXPAND_ALL_FILE_FOLDERS], }); +/** + * Interceptor responsible for processing RESIZE_COLUMN action into + * automatic width adjustment based on whether the user selected a specific width + * or if they just want the default auto-size behavior + */ +const resizeColumnLogic = createLogic({ + async process(deps: ReduxLogicDeps, dispatch, done) { + const { payload: column } = deps.action as ResizeColumnAction; + const columns = selectionSelectors.getColumns(deps.getState()); + + let width = column.width; + if (!width) { + // TODO: To come in follow-up + // const autoSizedWidth = await annotationService.fetchOptimalWidthForAnnotations( + // [column.name], + // true + // ); + // width = autoSizedWidth[column.name] as number; + width = DEFAULT_COLUMN_WIDTH; + } + + dispatch( + setColumns( + columns.map( + (c) => ({ ...c, width: c.name === column.name ? width : c.width } as Column) + ) + ) + ); + done(); + }, + type: RESIZE_COLUMN, +}); + /** * Interceptor responsible for processing DECODE_FILE_EXPLORER_URL actions into various * other actions responsible for rehydrating the SearchParams into application state. @@ -904,4 +940,5 @@ export default [ setDataSourceReloadErrorLogic, changeQueryLogic, removeQueryLogic, + resizeColumnLogic, ]; diff --git a/packages/core/state/selection/reducer.ts b/packages/core/state/selection/reducer.ts index 2859dc7c4..d41d9a419 100644 --- a/packages/core/state/selection/reducer.ts +++ b/packages/core/state/selection/reducer.ts @@ -7,7 +7,6 @@ import { SET_FILE_FILTERS, SET_FILE_SELECTION, SET_OPEN_FILE_FOLDERS, - RESIZE_COLUMN, SORT_COLUMN, SET_SORT_COLUMN, CHANGE_DATA_SOURCES, @@ -31,7 +30,6 @@ import { SetRequiresDataSourceReload, SET_FILE_VIEW, SetFileView, - ResizeColumnAction, Column, SetColumns, SET_COLUMNS, @@ -40,13 +38,16 @@ import { CHANGE_PROVENANCE_SOURCE, ChangeProvenanceSource, SET_IS_LOADING_DATA_SOURCE, + REORDER_COLUMNS, + ReorderColumnsAction, } from "./actions"; import interaction from "../interaction"; -import { FileView, Source } from "../../entity/SearchParams"; +import { TOP_LEVEL_FILE_ANNOTATIONS } from "../../constants"; import FileFilter from "../../entity/FileFilter"; import FileFolder from "../../entity/FileFolder"; import FileSelection from "../../entity/FileSelection"; import FileSort, { SortOrder } from "../../entity/FileSort"; +import { DEFAULT_COLUMN_WIDTH, FileView, Source } from "../../entity/SearchParams"; import Tutorial from "../../entity/Tutorial"; import Tutorials from "../../hooks/useHelpOptions/Tutorials"; @@ -208,16 +209,40 @@ export default makeReducer( availableAnnotationsForHierarchyLoading: true, fileSelection: new FileSelection(), }), - [RESIZE_COLUMN]: (state, action: ResizeColumnAction) => ({ - ...state, - columns: state.columns.map((column) => - column.name !== action.payload.name ? column : action.payload - ), - }), [SET_COLUMNS]: (state, action: SetColumns) => ({ ...state, columns: action.payload, }), + [REORDER_COLUMNS]: (state, action: ReorderColumnsAction) => { + let columns = [...state.columns]; + for (const reorder of action.payload) { + const remaining = columns.filter((col) => reorder.name !== col.name); + let moving = columns.find((col) => reorder.name === col.name); + if (!moving) { + // Check for matching column in special top level annotations like File Name + // and if still no match just skip + const matchingSpecialAnnotation = TOP_LEVEL_FILE_ANNOTATIONS.find( + (a) => reorder.name === a.name || reorder.name === a.displayName + ); + if (!matchingSpecialAnnotation) { + continue; + } + moving = { + name: matchingSpecialAnnotation.name, + width: DEFAULT_COLUMN_WIDTH, + }; + } + + const moveTo = Math.min(reorder.moveTo, remaining.length); + columns = [ + ...remaining.slice(0, moveTo), + // Optionally update widths of moved columns if provided in the action + { ...moving, width: reorder.width ?? moving.width }, + ...remaining.slice(moveTo), + ]; + } + return { ...state, columns }; + }, [SET_FILE_SELECTION]: (state, action) => ({ ...state, fileSelection: action.payload, diff --git a/packages/core/state/selection/selectors.ts b/packages/core/state/selection/selectors.ts index 73c9460c8..6506b04b1 100644 --- a/packages/core/state/selection/selectors.ts +++ b/packages/core/state/selection/selectors.ts @@ -42,10 +42,8 @@ export const hasProvenanceSource = createSelector( (source): boolean => !!source ); -export const isColumnWidthOverflowing = createSelector( - [getColumns, getFileView], - (columns, fileView): boolean => - fileView === FileView.LIST && columns.reduce((acc, column) => acc + column.width, 0) > 1 +export const getTotalColumnWidth = createSelector([getColumns], (columns): number => + columns.reduce((acc, column) => acc + column.width, 0) ); export const isQueryingAicsFms = createSelector( diff --git a/packages/core/state/selection/test/reducer.test.ts b/packages/core/state/selection/test/reducer.test.ts index 40b5639df..8c01bb7fc 100644 --- a/packages/core/state/selection/test/reducer.test.ts +++ b/packages/core/state/selection/test/reducer.test.ts @@ -92,7 +92,7 @@ describe("Selection reducer", () => { const state = { ...selection.initialState, annotationHierarchy: ["Cell Line"], - columns: [{ name: "file_id", width: 0.5 }], + columns: [{ name: "file_id", width: 200 }], filters: [new FileFilter("file_id", "1238401234")], fileView: FileView.LIST, openFileFolders: [new FileFolder(["AICS-11"])], @@ -193,11 +193,11 @@ describe("Selection reducer", () => { // arrange const initialSelectionState = { ...selection.initialState, - columns: [{ name: "Green", width: 0.11 }], + columns: [{ name: "Green", width: 110 }], }; const columns = [ - { name: "Orange", width: 0.42 }, - { name: "Red", width: 0.47 }, + { name: "Orange", width: 250 }, + { name: "Red", width: 180 }, ]; const action = selection.actions.setColumns(columns); @@ -215,6 +215,115 @@ describe("Selection reducer", () => { }); }); + describe("REORDER_COLUMNS", () => { + it("moves a single column to a new index", () => { + // arrange + const state = { + ...selection.initialState, + columns: [ + { name: "A", width: 100 }, + { name: "B", width: 100 }, + { name: "C", width: 100 }, + { name: "D", width: 100 }, + ], + }; + const action = selection.actions.reorderColumns([{ name: "C", moveTo: 0 }]); + + // act + const nextState = selection.reducer(state, action); + + // assert + expect( + selection.selectors.getColumns({ ...initialState, selection: nextState }) + ).to.deep.equal([ + { name: "C", width: 100 }, + { name: "A", width: 100 }, + { name: "B", width: 100 }, + { name: "D", width: 100 }, + ]); + }); + + it("applies multiple reorder operations sequentially", () => { + // arrange + const state = { + ...selection.initialState, + columns: [ + { name: "A", width: 100 }, + { name: "B", width: 100 }, + { name: "C", width: 100 }, + { name: "D", width: 100 }, + ], + }; + const action = selection.actions.reorderColumns([ + { name: "D", moveTo: 0 }, + { name: "B", moveTo: 3 }, + ]); + + // act + const nextState = selection.reducer(state, action); + + // assert + expect( + selection.selectors.getColumns({ ...initialState, selection: nextState }) + ).to.deep.equal([ + { name: "D", width: 100 }, + { name: "A", width: 100 }, + { name: "C", width: 100 }, + { name: "B", width: 100 }, + ]); + }); + + it("clamps moveTo to the end of the list when out of bounds", () => { + // arrange + const state = { + ...selection.initialState, + columns: [ + { name: "A", width: 100 }, + { name: "B", width: 100 }, + { name: "C", width: 100 }, + ], + }; + const action = selection.actions.reorderColumns([{ name: "A", moveTo: 99 }]); + + // act + const nextState = selection.reducer(state, action); + + // assert + expect( + selection.selectors.getColumns({ ...initialState, selection: nextState }) + ).to.deep.equal([ + { name: "B", width: 100 }, + { name: "C", width: 100 }, + { name: "A", width: 100 }, + ]); + }); + + it("optionally updates width of moved column", () => { + // arrange + const state = { + ...selection.initialState, + columns: [ + { name: "A", width: 100 }, + { name: "B", width: 100 }, + { name: "C", width: 100 }, + ], + }; + const action = selection.actions.reorderColumns([{ name: "A", moveTo: 2, width: 200 }]); + + // act + const nextState = selection.reducer(state, action); + + // assert + expect( + selection.selectors.getColumns({ ...initialState, selection: nextState }) + ).to.deep.equal([ + { name: "B", width: 100 }, + { name: "C", width: 100 }, + { name: "A", width: 200 }, + ]); + }); + }); + describe("SET_FILE_FILTERS", () => { it("sets file filters", () => { // Arrange diff --git a/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts b/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts index de5de647e..8ff5d3780 100644 --- a/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts +++ b/packages/desktop/src/services/test/PersistentConfigServiceElectron.test.ts @@ -108,8 +108,8 @@ describe(`${RUN_IN_RENDERER} PersistentConfigServiceElectron`, () => { const config = { [PersistedConfigKeys.AllenMountPoint]: "/some/path/to/allen", [PersistedConfigKeys.Columns]: [ - { name: "a", width: 0.25 }, - { name: "b", width: 0.3 }, + { name: "a", width: 200 }, + { name: "b", width: 250 }, ], [PersistedConfigKeys.CsvColumns]: ["a", "b"], [PersistedConfigKeys.ImageJExecutable]: "/my/imagej",