diff --git a/packages/core/components/AggregateInfoBox/index.tsx b/packages/core/components/AggregateInfoBox/index.tsx index 628115d1b..ad48af49c 100644 --- a/packages/core/components/AggregateInfoBox/index.tsx +++ b/packages/core/components/AggregateInfoBox/index.tsx @@ -46,11 +46,24 @@ export default function AggregateInfoBox() { setLoading(false); setError(undefined); } - } catch (requestError) { + } catch (err) { if (!ignoreResponse) { - setError( - `Whoops! Couldn't get aggregate information for some reason. ${requestError}` - ); + // If the selection was large and the server had an internal failure + // we likely just can't compile the data + if ( + (err as Error).message?.includes("Internal Server Error") && + fileSelection.count() > 10_000 + ) { + setAggregateData(undefined); + setLoading(false); + setError(undefined); + } else { + setError( + `Whoops! Couldn't get aggregate information for some reason. ${ + (err as Error).message + }` + ); + } } } }; @@ -80,19 +93,21 @@ export default function AggregateInfoBox() { Total Files
Selected -
-
- {!isLoading && aggregateData ? ( - aggregateData.count - ) : ( - - )} + {(isLoading || aggregateData?.count) && ( +
+
+ {!isLoading && aggregateData ? ( + aggregateData.count + ) : ( + + )} +
+
+ Unique Files
Selected +
-
- Unique Files
Selected -
-
- {aggregateData?.size && ( + )} + {(isLoading || aggregateData?.size) && (
{!isLoading && aggregateData ? ( diff --git a/packages/core/components/DirectoryTree/index.tsx b/packages/core/components/DirectoryTree/index.tsx index b47cf29a5..b0f0ae35b 100644 --- a/packages/core/components/DirectoryTree/index.tsx +++ b/packages/core/components/DirectoryTree/index.tsx @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from "react-redux"; import RootLoadingIndicator from "./RootLoadingIndicator"; import useDirectoryHierarchy from "./useDirectoryHierarchy"; +import useSelectAll from "./useSelectAll"; import AggregateInfoBox from "../AggregateInfoBox"; import EmptyFileListMessage from "../EmptyFileListMessage"; import FileSet from "../../entity/FileSet"; @@ -74,6 +75,9 @@ export default function DirectoryTree(props: FileListProps) { return () => window.removeEventListener("keydown", onArrowKeyDown, true); }, [dispatch, visibleModal]); + // Ctrl+A / Cmd+A: select all files in the last-opened folder (or root for flat lists) + useSelectAll(); + const { state: { content, error, isLoading }, } = useDirectoryHierarchy({ collapsed: false, fileSet, sortOrder: 0 }); diff --git a/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx b/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx index 47bfcdfe1..5a9994fb8 100644 --- a/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx +++ b/packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx @@ -19,12 +19,16 @@ import { import { Provider } from "react-redux"; import { createSandbox } from "sinon"; +import { ModalType } from "../../Modal"; import { FESBaseUrl, TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants"; import Annotation from "../../../entity/Annotation"; import AnnotationName from "../../../entity/Annotation/AnnotationName"; import { AnnotationType } from "../../../entity/AnnotationFormatter"; import { FmsFileAnnotation } from "../../../services/FileService"; -import FileFilter from "../../../entity/FileFilter"; +import FileFilter, { FilterType } from "../../../entity/FileFilter"; +import FileFolder from "../../../entity/FileFolder"; +import FileSet from "../../../entity/FileSet"; +import NumericRange from "../../../entity/NumericRange"; import FileDownloadServiceNoop from "../../../services/FileDownloadService/FileDownloadServiceNoop"; import HttpFileService from "../../../services/FileService/HttpFileService"; import HttpAnnotationService from "../../../services/AnnotationService/HttpAnnotationService"; @@ -492,6 +496,228 @@ describe("", () => { expect(header.classList.contains(styles.focused)).to.be.true; }); + it("selects all files in root file set when Ctrl+A is pressed and there is no annotation hierarchy", async () => { + // Flat list: no annotation hierarchy, no folder tracking needed + const flatState = mergeState(initialState, { + metadata: { + annotations: [...baseDisplayAnnotations], + }, + selection: { + annotationHierarchy: [], + columns: baseDisplayAnnotations.map((a) => ({ name: a.name, width: 0.1 })), + }, + }); + + const { store } = configureMockStore({ + state: flatState, + responseStubs, + reducer, + logics: reduxLogics, + }); + + render( + + + + ); + + // Simulate Ctrl+A keydown + fireEvent.keyDown(window, { key: "a", ctrlKey: true }); + + // After the async fetchTotalCount resolves, the file selection should include all files + await waitFor(() => { + const fileSelection = selection.selectors.getFileSelection(store.getState()); + expect(fileSelection.count()).to.equal(totalFilesCount); + }); + }); + + it("selects all files in the last-touched folder when Ctrl+A is pressed and its folder is open", async () => { + // Track the last-touched folder directly in Redux state (set by the reducer + // when a file is selected, mimicked here by setting it directly for this test) + const lastTouchedFolder = new FileFolder([ + topLevelHierarchyValues[0], + secondLevelHierarchyValues[1], + ]); + + const stateWithLastTouchedFolder = mergeState(initialState, { + metadata: { + annotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation], + }, + selection: { + annotationHierarchy: [fooAnnotation.name, barAnnotation.name], + columns: [...baseDisplayAnnotations, fooAnnotation, barAnnotation].map((a) => ({ + name: a.name, + width: 0.1, + })), + lastTouchedFolder, + openFileFolders: [lastTouchedFolder], + }, + }); + + const { store } = configureMockStore({ + state: stateWithLastTouchedFolder, + responseStubs, + reducer, + logics: reduxLogics, + }); + + render( + + + + ); + + // Simulate Ctrl+A keydown + fireEvent.keyDown(window, { key: "a", ctrlKey: true }); + + // The sub-folder file set (foo=first, bar=b) should now have all its files selected + const expectedSubFolderFileSet = new FileSet({ + fileService, + filters: [ + new FileFilter(fooAnnotation.name, topLevelHierarchyValues[0], FilterType.DEFAULT), + new FileFilter( + barAnnotation.name, + secondLevelHierarchyValues[1], + FilterType.DEFAULT + ), + ], + }); + await waitFor(() => { + const fileSelection = selection.selectors.getFileSelection(store.getState()); + expect(fileSelection.count()).to.equal(totalFilesCount); + // All selected files must belong to the sub-folder file set, not the root + expect( + fileSelection.isSelected( + expectedSubFolderFileSet, + new NumericRange(0, totalFilesCount - 1) + ) + ).to.be.true; + }); + }); + + it("does nothing when Ctrl+A is pressed and the last-touched folder is closed", async () => { + // lastTouchedFolder is set but NOT in openFileFolders (folder was closed) + const lastTouchedFolder = new FileFolder([ + topLevelHierarchyValues[0], + secondLevelHierarchyValues[1], + ]); + + const stateWithClosedFolder = mergeState(initialState, { + metadata: { + annotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation], + }, + selection: { + annotationHierarchy: [fooAnnotation.name, barAnnotation.name], + columns: [...baseDisplayAnnotations, fooAnnotation, barAnnotation].map((a) => ({ + name: a.name, + width: 0.1, + })), + lastTouchedFolder, + openFileFolders: [], // folder is closed + }, + }); + + const { store } = configureMockStore({ + state: stateWithClosedFolder, + responseStubs, + reducer, + logics: reduxLogics, + }); + + render( + + + + ); + + // Simulate Ctrl+A keydown + fireEvent.keyDown(window, { key: "a", ctrlKey: true }); + + // Selection should remain empty since the last-touched folder is closed + await waitFor(() => { + const fileSelection = selection.selectors.getFileSelection(store.getState()); + expect(fileSelection.count()).to.equal(0); + }); + }); + + it("does nothing when Ctrl+A is pressed and no folder has been touched", async () => { + // Has a hierarchy but no lastTouchedFolder (user hasn't selected a file yet) + const stateWithNoLastTouched = mergeState(initialState, { + metadata: { + annotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation], + }, + selection: { + annotationHierarchy: [fooAnnotation.name, barAnnotation.name], + columns: [...baseDisplayAnnotations, fooAnnotation, barAnnotation].map((a) => ({ + name: a.name, + width: 0.1, + })), + }, + }); + + const { store } = configureMockStore({ + state: stateWithNoLastTouched, + responseStubs, + reducer, + logics: reduxLogics, + }); + + render( + + + + ); + + // Simulate Ctrl+A keydown + fireEvent.keyDown(window, { key: "a", ctrlKey: true }); + + // Selection should remain empty since no folder has been touched + await waitFor(() => { + const fileSelection = selection.selectors.getFileSelection(store.getState()); + expect(fileSelection.count()).to.equal(0); + }); + }); + + it("does not select all files when a modal is visible and Ctrl+A is pressed", async () => { + const stateWithModal = mergeState(initialState, { + interaction: { + visibleModal: ModalType.MetadataManifest, + }, + metadata: { + annotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation], + }, + selection: { + annotationHierarchy: [fooAnnotation.name, barAnnotation.name], + columns: [...baseDisplayAnnotations, fooAnnotation, barAnnotation].map((a) => ({ + name: a.name, + width: 0.1, + })), + }, + }); + + const { store } = configureMockStore({ + state: stateWithModal, + responseStubs, + reducer, + logics: reduxLogics, + }); + + render( + + + + ); + + // Simulate Ctrl+A keydown while modal is open + fireEvent.keyDown(window, { key: "a", ctrlKey: true }); + + // File selection should remain empty since a modal is visible + await waitFor(() => { + const fileSelection = selection.selectors.getFileSelection(store.getState()); + expect(fileSelection.count()).to.equal(0); + }); + }); + it("displays root loading indicator when new query is in loading state", async () => { // Arrange const state = mergeState(initialState, { diff --git a/packages/core/components/DirectoryTree/useSelectAll.ts b/packages/core/components/DirectoryTree/useSelectAll.ts new file mode 100644 index 000000000..380364ad6 --- /dev/null +++ b/packages/core/components/DirectoryTree/useSelectAll.ts @@ -0,0 +1,104 @@ +import * as React from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import FileFilter, { FilterType } from "../../entity/FileFilter"; +import FileSet from "../../entity/FileSet"; +import NumericRange from "../../entity/NumericRange"; +import { interaction, selection } from "../../state"; + +/** + * React hook that registers a Ctrl+A / Cmd+A keyboard shortcut to select all + * files within the last-opened folder. + * + * Behavior: + * - If there is no annotation hierarchy (flat list), selects all files in the + * root file set. + * - If there is an annotation hierarchy, selects all files in the folder the + * user most recently selected a file within — but only if that folder + * is still present in `openFileFolders`. Does nothing otherwise. + * - No-ops while a modal overlay is visible (consistent with arrow-key behavior). + */ +export default function useSelectAll() { + const dispatch = useDispatch(); + const fileService = useSelector(interaction.selectors.getFileService); + const globalFilters = useSelector(selection.selectors.getFileFilters); + const sortColumn = useSelector(selection.selectors.getSortColumn); + const visibleModal = useSelector(interaction.selectors.getVisibleModal); + const annotationHierarchy = useSelector(selection.selectors.getAnnotationHierarchy); + const lastTouchedFolder = useSelector(selection.selectors.getLastTouchedFolder); + + // Root file set (used for flat-list case) + const rootFileSet = React.useMemo( + () => + new FileSet({ + fileService, + filters: globalFilters, + sort: sortColumn, + }), + [fileService, globalFilters, sortColumn] + ); + + React.useEffect(() => { + const onSelectAllKeyDown = async (event: KeyboardEvent) => { + if (visibleModal) return; + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") { + event.preventDefault(); + + let targetFileSet: FileSet; + + if (annotationHierarchy.length === 0) { + // Flat list — no folder concept, always select all in root + targetFileSet = rootFileSet; + } else if (lastTouchedFolder) { + // Rebuild the FileSet for the last-touched folder by combining + // the hierarchy filters (one per level from the folder path) with + // any non-hierarchy global filters the user may have applied. + const hierarchyFilters = annotationHierarchy.map( + (annotationName, idx) => + new FileFilter( + annotationName, + lastTouchedFolder.fileFolder[idx], + FilterType.DEFAULT + ) + ); + const nonHierarchyFilters = globalFilters.filter( + (f) => + !(annotationHierarchy.includes(f.name) && f.type === FilterType.DEFAULT) + ); + targetFileSet = new FileSet({ + fileService, + filters: [...hierarchyFilters, ...nonHierarchyFilters], + sort: sortColumn, + }); + } else { + // No last-touched folder or the folder has since been closed — do nothing + return; + } + + const totalCount = await targetFileSet.fetchTotalCount(); + if (totalCount > 0) { + dispatch( + selection.actions.selectFile({ + fileSet: targetFileSet, + selection: new NumericRange(0, totalCount - 1), + sortOrder: 0, + updateExistingSelection: false, + }) + ); + } + } + }; + + window.addEventListener("keydown", onSelectAllKeyDown, true); + return () => window.removeEventListener("keydown", onSelectAllKeyDown, true); + }, [ + annotationHierarchy, + dispatch, + fileService, + globalFilters, + lastTouchedFolder, + rootFileSet, + sortColumn, + visibleModal, + ]); +} diff --git a/packages/core/state/selection/logics.ts b/packages/core/state/selection/logics.ts index de4b5a1c6..c12af36d8 100644 --- a/packages/core/state/selection/logics.ts +++ b/packages/core/state/selection/logics.ts @@ -115,7 +115,11 @@ const selectFile = createLogic({ fileSet, index: selection, sortOrder, - indexToFocus: lastTouched, + // If selection is a number set it to be indexToFocus otherwise + // if selection is a NumericRange set the indexToFocus to be the start of the range + // (this is somewhat arbitrary but it is consistent with how we handle range selections + // in other places in the app and seems reasonable as a default) + indexToFocus: typeof selection === "number" ? selection : selection.min, }); next(setFileSelection(nextFileSelection)); }, diff --git a/packages/core/state/selection/reducer.ts b/packages/core/state/selection/reducer.ts index 2859dc7c4..5cd11dafe 100644 --- a/packages/core/state/selection/reducer.ts +++ b/packages/core/state/selection/reducer.ts @@ -40,6 +40,8 @@ import { CHANGE_PROVENANCE_SOURCE, ChangeProvenanceSource, SET_IS_LOADING_DATA_SOURCE, + SetOpenFileFoldersAction, + SetFileSelection, } from "./actions"; import interaction from "../interaction"; import { FileView, Source } from "../../entity/SearchParams"; @@ -60,6 +62,7 @@ export interface SelectionStateBranch { fileView: FileView; filters: FileFilter[]; isLoadingDataSource: boolean; + lastTouchedFolder?: FileFolder; openFileFolders: FileFolder[]; recentAnnotations: string[]; requiresDataSourceReload?: boolean; @@ -152,6 +155,7 @@ export default makeReducer( ...state, dataSources: uniqBy(action.payload, "name"), fileSelection: new FileSelection(), + lastTouchedFolder: undefined, openFileFolders: [], }), [CHANGE_PROVENANCE_SOURCE]: (state, action: ChangeProvenanceSource) => ({ @@ -180,6 +184,7 @@ export default makeReducer( columns: initialState.columns, filters: initialState.filters, fileView: initialState.fileView, + lastTouchedFolder: undefined, openFileFolders: initialState.openFileFolders, shouldShowNullGroups: initialState.shouldShowNullGroups, sortColumn: undefined, @@ -218,10 +223,26 @@ export default makeReducer( ...state, columns: action.payload, }), - [SET_FILE_SELECTION]: (state, action) => ({ - ...state, - fileSelection: action.payload, - }), + [SET_FILE_SELECTION]: (state, action: SetFileSelection) => { + const focusedItem = action.payload.focusedItem; + const filters = focusedItem?.fileSet.filters ?? []; + // Build a FileFolder from only the hierarchy-level filters (in hierarchy + // order) so it matches the openFileFolders shape. Global filters that are + // not part of the hierarchy must be excluded; otherwise the folder path + // will be too long and the Ctrl+A select-all shortcut won't match it. + const hierarchyValues = state.annotationHierarchy + .map((name) => filters.find((f) => f.name === name)) + .filter((f): f is FileFilter => f !== undefined) + .map((f) => f.value); + return { + ...state, + fileSelection: action.payload, + lastTouchedFolder: + hierarchyValues.length > 0 + ? new FileFolder(hierarchyValues) + : state.lastTouchedFolder, + }; + }, [SET_ANNOTATION_HIERARCHY]: (state, action) => ({ ...state, annotationHierarchy: action.payload, @@ -238,12 +259,30 @@ export default makeReducer( }), [COLLAPSE_ALL_FILE_FOLDERS]: (state) => ({ ...state, + lastTouchedFolder: undefined, openFileFolders: [], }), - [SET_OPEN_FILE_FOLDERS]: (state, action) => ({ - ...state, - openFileFolders: action.payload, - }), + [SET_OPEN_FILE_FOLDERS]: (state, action: SetOpenFileFoldersAction) => { + const openFileFolders = action.payload; + // If folders are being opened (as opposed to closed), update the open folders and last touched folder accordingly + if (openFileFolders.length > state.openFileFolders.length) { + return { + ...state, + openFileFolders, + }; + } + // If the last-touched folder is still open, keep it as the last-touched folder + // otherwise it will become undefined which will cause the directory tree to + // lose track of which folder the user is in and reset + const lastTouchedFolder = openFileFolders.find((f) => + f.equals(state.lastTouchedFolder) + ); + return { + ...state, + lastTouchedFolder: lastTouchedFolder, + openFileFolders, + }; + }, [TOGGLE_NULL_VALUE_GROUPS]: (state, action) => ({ ...state, shouldShowNullGroups: diff --git a/packages/core/state/selection/selectors.ts b/packages/core/state/selection/selectors.ts index 73c9460c8..e803cb2cd 100644 --- a/packages/core/state/selection/selectors.ts +++ b/packages/core/state/selection/selectors.ts @@ -19,6 +19,7 @@ export const getFileFilters = (state: State) => state.selection.filters; export const getFileSelection = (state: State) => state.selection.fileSelection; export const getFileView = (state: State) => state.selection.fileView; export const getIsLoadingSource = (state: State) => state.selection.isLoadingDataSource; +export const getLastTouchedFolder = (state: State) => state.selection.lastTouchedFolder; export const getOpenFileFolders = (state: State) => state.selection.openFileFolders; export const getRecentAnnotations = (state: State) => state.selection.recentAnnotations; export const getRequiresDataSourceReload = (state: State) => diff --git a/packages/core/state/selection/test/reducer.test.ts b/packages/core/state/selection/test/reducer.test.ts index 40b5639df..088255d66 100644 --- a/packages/core/state/selection/test/reducer.test.ts +++ b/packages/core/state/selection/test/reducer.test.ts @@ -7,7 +7,7 @@ import interaction from "../../interaction"; import { Environment } from "../../../constants"; import AnnotationName from "../../../entity/Annotation/AnnotationName"; import FileDetail from "../../../entity/FileDetail"; -import FileFilter from "../../../entity/FileFilter"; +import FileFilter, { FilterType } from "../../../entity/FileFilter"; import FileFolder from "../../../entity/FileFolder"; import FileSelection from "../../../entity/FileSelection"; import FileSet from "../../../entity/FileSet"; @@ -414,4 +414,203 @@ describe("Selection reducer", () => { ).to.be.equal(0); }); }); + + describe("SET_FILE_SELECTION", () => { + it("sets lastTouchedFolder from hierarchy filters only, excluding global filters", () => { + // Arrange + const hierarchy = ["Cell Line", "Workflow"]; + const globalFilter = new FileFilter("Scientist", "Jane", FilterType.DEFAULT); + const fileSet = new FileSet({ + filters: [ + new FileFilter("Cell Line", "AICS-11"), + new FileFilter("Workflow", "Pipeline 4"), + globalFilter, + ], + }); + const fileSelection = new FileSelection().select({ + fileSet, + index: new NumericRange(0, 5), + sortOrder: 0, + }); + const state = { + ...selection.initialState, + annotationHierarchy: hierarchy, + filters: [globalFilter], + }; + + // Act + const nextState = selection.reducer( + state, + selection.actions.setFileSelection(fileSelection) + ); + + // Assert + expect(nextState.lastTouchedFolder).to.not.be.undefined; + expect(nextState.lastTouchedFolder?.fileFolder).to.deep.equal([ + "AICS-11", + "Pipeline 4", + ]); + }); + + it("preserves existing lastTouchedFolder when selection has no hierarchy filters (root level)", () => { + // Arrange + const existingFolder = new FileFolder(["AICS-11"]); + const fileSet = new FileSet({ + filters: [new FileFilter("Scientist", "Jane", FilterType.FUZZY)], + }); + const fileSelection = new FileSelection().select({ + fileSet, + index: 0, + sortOrder: 0, + }); + const state = { + ...selection.initialState, + annotationHierarchy: ["Cell Line"], + lastTouchedFolder: existingFolder, + }; + + // Act + const nextState = selection.reducer( + state, + selection.actions.setFileSelection(fileSelection) + ); + + // Assert + expect(nextState.lastTouchedFolder).to.equal(existingFolder); + }); + + it("sets lastTouchedFolder when no global filters are present", () => { + // Arrange + const hierarchy = ["Cell Line"]; + const fileSet = new FileSet({ + filters: [new FileFilter("Cell Line", "AICS-24")], + }); + const fileSelection = new FileSelection().select({ + fileSet, + index: new NumericRange(0, 2), + sortOrder: 0, + }); + const state = { + ...selection.initialState, + annotationHierarchy: hierarchy, + }; + + // Act + const nextState = selection.reducer( + state, + selection.actions.setFileSelection(fileSelection) + ); + + // Assert + expect(nextState.lastTouchedFolder).to.not.be.undefined; + expect(nextState.lastTouchedFolder?.fileFolder).to.deep.equal(["AICS-24"]); + }); + + it("preserves existing lastTouchedFolder when selection has no focused item", () => { + // Arrange + const existingFolder = new FileFolder(["AICS-11"]); + const emptySelection = new FileSelection(); + const state = { + ...selection.initialState, + annotationHierarchy: ["Cell Line"], + lastTouchedFolder: existingFolder, + }; + + // Act + const nextState = selection.reducer( + state, + selection.actions.setFileSelection(emptySelection) + ); + + // Assert + expect(nextState.lastTouchedFolder).to.equal(existingFolder); + }); + }); + + describe("SET_OPEN_FILE_FOLDERS", () => { + it("preserves lastTouchedFolder when a new folder is opened", () => { + // Arrange + const existingFolder = new FileFolder(["AICS-11"]); + const newFolder = new FileFolder(["AICS-11", "Pipeline 4"]); + const state = { + ...selection.initialState, + openFileFolders: [existingFolder], + lastTouchedFolder: existingFolder, + }; + + // Act + const nextState = selection.reducer( + state, + selection.actions.setOpenFileFolders([existingFolder, newFolder]) + ); + + // Assert + expect(nextState.lastTouchedFolder).to.not.be.undefined; + expect(nextState.lastTouchedFolder?.fileFolder).to.deep.equal(["AICS-11"]); + }); + + it("keeps lastTouchedFolder when it is still open after a folder is closed", () => { + // Arrange + const folder1 = new FileFolder(["AICS-11"]); + const folder2 = new FileFolder(["AICS-24"]); + const leafFolder = new FileFolder(["AICS-11", "Pipeline 4"]); + const state = { + ...selection.initialState, + openFileFolders: [folder1, folder2, leafFolder], + lastTouchedFolder: leafFolder, + }; + + // Act — close folder2, but leafFolder is still open + const nextState = selection.reducer( + state, + selection.actions.setOpenFileFolders([folder1, leafFolder]) + ); + + // Assert + expect(nextState.lastTouchedFolder).to.not.be.undefined; + expect(nextState.lastTouchedFolder?.fileFolder).to.deep.equal([ + "AICS-11", + "Pipeline 4", + ]); + }); + + it("clears lastTouchedFolder when it is no longer open", () => { + // Arrange + const folder1 = new FileFolder(["AICS-11"]); + const folder2 = new FileFolder(["AICS-24"]); + const state = { + ...selection.initialState, + openFileFolders: [folder1, folder2], + lastTouchedFolder: folder2, + }; + + // Act — close folder2 + const nextState = selection.reducer( + state, + selection.actions.setOpenFileFolders([folder1]) + ); + + // Assert + expect(nextState.lastTouchedFolder).to.be.undefined; + }); + + it("updates openFileFolders to the provided list", () => { + // Arrange + const folder1 = new FileFolder(["AICS-11"]); + const folder2 = new FileFolder(["AICS-24"]); + const state = { + ...selection.initialState, + openFileFolders: [folder1], + }; + + // Act + const nextState = selection.reducer( + state, + selection.actions.setOpenFileFolders([folder1, folder2]) + ); + + // Assert + expect(nextState.openFileFolders).to.have.length(2); + }); + }); });