wrolpi/app/src/contexts/contexts.js
Roland 35151a5fb0 test: one place for fixtures, real providers instead of mocked context modules
Specs had no shared vocabulary for the app's data or its contexts, so each one
rebuilt both.  A settings object was written out by hand wherever one was needed,
twenty-odd keys at a time, and nothing held those copies to each other or to the
API.  Contexts were worse: to render a consumer a spec mocked the whole context
module, which replaces every export including the providers, so each spec
reinvented the pieces it still needed.  One supplies a `Media` that always
reports desktop -- quietly leaving that component's mobile branch untested -- and
one hand-writes a context as a bare `_currentValue` with no provider, working only
because useContext reads that React internal.

test-fixtures.js is now the single definition of both, shared by jest, the
Cypress component specs, and e2e intercept bodies, so the three cannot disagree
about what the API returns.  Every fixture is a function taking overrides; a
shared mutable object lets one test change what the next one sees.

The drift this prevents was already real.  The theme fixture supplied `i`, `s`
and `t` holding hardcoded Semantic greys for months after ThemeProvider stopped
supplying them, and ThemeContext still declared all four.  So the fixture agreed
with the tests, and with nothing else: FileBrowser was reading `inverted` off the
context and pasting the resulting `undefined` into a className, which is why the
file browser's footer -- near-white #f9fafb, with a `.inverted` dark variant that
nothing could select any more -- stayed light in dark, night and amber.  A white
bar pinned to the bottom of the screen in night mode costs the user exactly the
dark adaptation the theme exists to protect.  It takes tokens now.

test-fixtures.test.js holds each fixture to its context's declared shape in both
directions, so a property added to one and forgotten in the other fails a test.

Two failed attempts are recorded in test-utils.js because both were instructive.
Importing Tags.js and FileWorkerStatusContext.js so the harness could supply
those providers broke eight suites: it put both into the module graph of every
spec ahead of that spec's own mocks, and components bound to a second instance of
a mocked module -- a different jest.fn() than the test had configured, so a hook
mocked to return a value returned undefined, in a file no test mentions.
Requiring them lazily fixed that and not its mirror image: useContext read a
different TagsContext than the harness had required, so the provider was ignored
and the context default won.  A harness must not change what a spec imports, so
it owns only contexts/contexts.js and a spec passes any other context itself.

DomainTagging is converted off the `_currentValue` fake as proof, and now asserts
the tag name a user sees rather than a test id its own mock invented.  The five
remaining specs are listed in a ratchet so the count can only fall.

974 jest, 15 browser component, 46 e2e, clean cache-busted build, tsc clean.
Fixtures are confirmed absent from the shipped bundle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 16:44:40 -06:00

293 lines
11 KiB
JavaScript

import React, {useCallback, useEffect, useRef, useState} from "react";
import {createMedia} from "@artsy/fresnel";
// Touch device detection hook (defined here to avoid circular import with customHooks)
export const useIsTouchDevice = () => {
const [isTouchDevice, setIsTouchDevice] = useState(false);
useEffect(() => {
// Check if matchMedia is available (not in jsdom test environment)
if (typeof window.matchMedia !== 'function') return;
const touchQuery = window.matchMedia('(pointer: coarse)');
// Guard against matchMedia returning null/undefined
if (!touchQuery) return;
setIsTouchDevice(touchQuery.matches);
const handleChange = (e) => setIsTouchDevice(e.matches);
touchQuery.addEventListener('change', handleChange);
return () => touchQuery.removeEventListener('change', handleChange);
}, []);
return isTouchDevice;
};
/** @type {React.Context<import('../types/theme').ThemeContextValue>} */
/*
* The default is the declared shape of this context, and test-fixtures.test.js holds the
* fixtures to it key for key. It used to carry `i`, `s`, `t` and `inverted` -- Semantic
* compatibility props holding hardcoded greys -- long after ThemeProvider stopped supplying
* them, so the context advertised four properties that were always undefined in a real tree.
* FileBrowser was still reading one of them.
*/
export const ThemeContext = React.createContext({
theme: 'light',
savedTheme: null,
isDark: false,
mediaFilter: undefined,
mediaFilterEnabled: false,
setMediaFilterEnabled: () => {},
setTheme: () => {},
setDarkTheme: () => {},
setLightTheme: () => {},
cycleSavedTheme: () => {},
});
export const StatusContext = React.createContext({
status: {},
fetchStatus: null,
});
export const SettingsContext = React.createContext({
settings: {},
fetchSettings: null,
saveSettings: null,
pending: false,
});
// `useQuery`
export const QueryContext = React.createContext({
searchParams: null,
setSearchParams: null,
updateQuery: null,
getLocationStr: null,
});
export const AppMedia = createMedia({
breakpoints: {
mobile: 0, tablet: 700, wideTablet: 880, computer: 1024,
}
});
export const mediaStyles = AppMedia.createMediaStyle();
export const {Media, MediaContextProvider} = AppMedia;
// Flatten browseFiles tree into ordered list of paths for drag selection
// Must match the sort order used by SortableTable (default: path alphabetically)
function flattenBrowseFiles(files, openFolders) {
const result = [];
const traverse = (items) => {
if (!items) return;
const itemList = Array.isArray(items) ? items : Object.values(items);
// Sort alphabetically by path (lowercase) to match SortableTable default sort
const sortedItems = [...itemList].sort((a, b) =>
a.path.toLowerCase().localeCompare(b.path.toLowerCase())
);
sortedItems.forEach(item => {
result.push(item.path);
if (item.children && openFolders && openFolders.includes(item.path)) {
traverse(item.children);
}
});
};
traverse(files);
return result;
}
// Drag Selection Context for FileBrowser
export const DragSelectionContext = React.createContext({
isDragging: false,
handleDragStart: () => {},
handleDragMove: () => {},
getDragState: () => ({isDragSelecting: false, isDragDeselecting: false}),
shouldAllowClick: () => true,
// Selection state and functions
selectedPaths: [],
onSelect: () => {},
isSelected: () => false,
clearSelection: () => {},
});
export function DragSelectionProvider({children, selectedPaths, setSelectedPaths, browseFiles, openFolders}) {
const DRAG_THRESHOLD = 5; // Pixels to move before considering it a drag
// Drag selection state
const isTouchDevice = useIsTouchDevice();
const [isDragging, setIsDragging] = useState(false);
const [isMouseDown, setIsMouseDown] = useState(false);
const [dragStartPath, setDragStartPath] = useState(null);
const [dragSelection, setDragSelection] = useState(new Set());
const [dragMode, setDragMode] = useState('select'); // 'select' or 'deselect'
const allRowPathsRef = useRef([]);
const dragStartPosRef = useRef({x: 0, y: 0});
// Track pending drag start - stored in refs to avoid visual changes before threshold
const pendingDragPathRef = useRef(null);
const pendingDragModeRef = useRef('select');
// Track if a drag just completed - used to prevent onClick from firing after drag
const dragJustCompletedRef = useRef(false);
// Update ordered list of visible row paths when files change
useEffect(() => {
if (browseFiles) {
allRowPathsRef.current = flattenBrowseFiles(browseFiles, openFolders);
}
}, [browseFiles, openFolders]);
// Drag selection handlers
const handleDragStart = useCallback((path, event) => {
// Only start drag on left mouse button, and not on touch devices
if (isTouchDevice || event.button !== 0) return;
// Don't start drag if clicking on checkbox or inside it
if (event.target.closest('input[type="checkbox"]') || event.target.closest('.ui.checkbox')) return;
// Determine mode: if starting from selected item, we're deselecting; otherwise selecting
const mode = selectedPaths.includes(path) ? 'deselect' : 'select';
// Store in refs - don't set dragSelection yet to avoid visual flap
// Visual changes only happen after drag threshold is exceeded
dragStartPosRef.current = {x: event.clientX, y: event.clientY};
pendingDragPathRef.current = path;
pendingDragModeRef.current = mode;
setIsMouseDown(true);
}, [isTouchDevice, selectedPaths]);
const handleDragMove = useCallback((path) => {
if (!dragStartPath) return;
// Check if we've moved enough to consider it a drag
if (!isDragging) return;
// Calculate all paths between dragStartPath and current path
const allPaths = allRowPathsRef.current;
const startIndex = allPaths.indexOf(dragStartPath);
const currentIndex = allPaths.indexOf(path);
if (startIndex === -1 || currentIndex === -1) return;
const minIndex = Math.min(startIndex, currentIndex);
const maxIndex = Math.max(startIndex, currentIndex);
// Update drag selection to include all paths in range
setDragSelection(new Set(allPaths.slice(minIndex, maxIndex + 1)));
}, [isDragging, dragStartPath]);
const handleDragEnd = useCallback(() => {
if (!isMouseDown) return;
const draggedPaths = Array.from(dragSelection);
if (draggedPaths.length > 0 && isDragging) {
let newSelectedPaths;
if (dragMode === 'deselect') {
// Remove dragged paths from selection
newSelectedPaths = selectedPaths.filter(p => !dragSelection.has(p));
} else {
// Add dragged paths to selection (use Set to avoid duplicates)
newSelectedPaths = [...new Set([...selectedPaths, ...draggedPaths])];
}
setSelectedPaths(newSelectedPaths);
// Mark that a drag just completed - onClick handlers should not fire
dragJustCompletedRef.current = true;
}
// Reset all drag state
setIsMouseDown(false);
setIsDragging(false);
setDragStartPath(null);
setDragSelection(new Set());
setDragMode('select');
pendingDragPathRef.current = null;
pendingDragModeRef.current = 'select';
}, [isMouseDown, isDragging, selectedPaths, dragSelection, dragMode, setSelectedPaths]);
// Global mouse event listeners for drag selection
useEffect(() => {
if (!isMouseDown) return;
const handleGlobalMouseMove = (e) => {
if (!isDragging && pendingDragPathRef.current) {
// Check if we've moved enough to start dragging
const dx = e.clientX - dragStartPosRef.current.x;
const dy = e.clientY - dragStartPosRef.current.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > DRAG_THRESHOLD) {
// NOW initialize drag state - this triggers visual feedback
setDragStartPath(pendingDragPathRef.current);
setDragSelection(new Set([pendingDragPathRef.current]));
setDragMode(pendingDragModeRef.current);
setIsDragging(true);
}
}
};
const handleGlobalMouseUp = () => handleDragEnd();
const handleSelectStart = (e) => {
if (isMouseDown) e.preventDefault();
};
document.addEventListener('mousemove', handleGlobalMouseMove);
document.addEventListener('mouseup', handleGlobalMouseUp);
document.addEventListener('selectstart', handleSelectStart);
return () => {
document.removeEventListener('mousemove', handleGlobalMouseMove);
document.removeEventListener('mouseup', handleGlobalMouseUp);
document.removeEventListener('selectstart', handleSelectStart);
};
}, [isMouseDown, isDragging, handleDragEnd, DRAG_THRESHOLD]);
// Helper function for components to compute their visual drag state
const getDragState = useCallback((path, isSelected) => {
const inDragRange = dragSelection.has(path);
const isDragSelecting = inDragRange && dragMode === 'select';
const isDragDeselecting = inDragRange && dragMode === 'deselect' && isSelected;
return {isDragSelecting, isDragDeselecting};
}, [dragSelection, dragMode]);
// Check if a click should be allowed (not blocked by a recent drag)
// This is called by onClick handlers to determine if they should proceed
const shouldAllowClick = useCallback(() => {
if (dragJustCompletedRef.current) {
dragJustCompletedRef.current = false;
return false;
}
return true;
}, []);
// Selection functions - moved from FileBrowser to eliminate prop drilling
const onSelect = useCallback((path) => {
setSelectedPaths(prev => {
if (prev.includes(path)) {
return prev.filter(i => i !== path);
}
return [...prev, path];
});
}, [setSelectedPaths]);
const isSelected = useCallback((path) => {
return selectedPaths.includes(path);
}, [selectedPaths]);
const clearSelection = useCallback(() => {
setSelectedPaths([]);
}, [setSelectedPaths]);
const value = {
isDragging,
handleDragStart,
handleDragMove,
getDragState,
shouldAllowClick,
// Selection state and functions
selectedPaths,
onSelect,
isSelected,
clearSelection,
};
return (
<DragSelectionContext.Provider value={value}>
{children}
</DragSelectionContext.Provider>
);
}