diff --git a/packages/app/obojobo-repository/shared/actions/dashboard-actions.js b/packages/app/obojobo-repository/shared/actions/dashboard-actions.js index 13b97db092..3ff9d84d6f 100644 --- a/packages/app/obojobo-repository/shared/actions/dashboard-actions.js +++ b/packages/app/obojobo-repository/shared/actions/dashboard-actions.js @@ -210,6 +210,12 @@ const deleteModule = draftId => ({ promise: apiDeleteModule(draftId).then(apiGetMyModules) }) +const BULK_DELETE_MODULES = 'BULK_DELETE_MODULES' +const bulkDeleteModules = draftIds => ({ + type: BULK_DELETE_MODULES, + promise: Promise.all(draftIds.map(id => apiDeleteModule(id))).then(apiGetMyModules) +}) + const CREATE_NEW_MODULE = 'CREATE_NEW_MODULE' const createNewModule = (useTutorial = false) => ({ type: CREATE_NEW_MODULE, @@ -222,6 +228,18 @@ const filterModules = searchString => ({ searchString }) +const SELECT_MODULES = 'SELECT_MODULES' +const selectModules = draftIds => ({ + type: SELECT_MODULES, + draftIds +}) + +const DESELECT_MODULES = 'DESELECT_MODULES' +const deselectModules = draftIds => ({ + type: DESELECT_MODULES, + draftIds +}) + const SHOW_MODULE_MORE = 'SHOW_MODULE_MORE' const showModuleMore = module => ({ type: SHOW_MODULE_MORE, @@ -278,14 +296,20 @@ module.exports = { CLEAR_PEOPLE_SEARCH_RESULTS, DELETE_MODULE_PERMISSIONS, DELETE_MODULE, + BULK_DELETE_MODULES, FILTER_MODULES, + SELECT_MODULES, + DESELECT_MODULES, SHOW_MODULE_MORE, SHOW_VERSION_HISTORY, RESTORE_VERSION, IMPORT_MODULE_FILE, CHECK_MODULE_LOCK, filterModules, + selectModules, + deselectModules, deleteModule, + bulkDeleteModules, closeModal, deleteModulePermissions, searchForUser, diff --git a/packages/app/obojobo-repository/shared/actions/dashboard-actions.test.js b/packages/app/obojobo-repository/shared/actions/dashboard-actions.test.js index 664cc1534b..a343625419 100644 --- a/packages/app/obojobo-repository/shared/actions/dashboard-actions.test.js +++ b/packages/app/obojobo-repository/shared/actions/dashboard-actions.test.js @@ -280,6 +280,47 @@ describe('Dashboard Actions', () => { return assertDeleteModuleRunsWithOptions('/api/drafts') }) + const assertBulkDeleteModulesRunsWithOptions = (secondaryLookupUrl, fetchBody, options) => { + global.fetch.mockResolvedValue(standardFetchResponse) + const actionReply = DashboardActions.bulkDeleteModules( + ['mockDraftId1', 'mockDraftId2'], + options + ) + + expect(global.fetch).toHaveBeenCalledTimes(2) + expect(global.fetch).toHaveBeenCalledWith('/api/drafts/mockDraftId1', { + ...defaultFetchOptions, + method: 'DELETE', + body: fetchBody + }) + expect(global.fetch).toHaveBeenCalledWith('/api/drafts/mockDraftId2', { + ...defaultFetchOptions, + method: 'DELETE', + body: fetchBody + }) + global.fetch.mockReset() + global.fetch.mockResolvedValueOnce({ + json: () => ({ value: 'mockSecondaryResponse' }) + }) + + expect(actionReply).toEqual({ + type: DashboardActions.BULK_DELETE_MODULES, + promise: expect.any(Object) + }) + + return actionReply.promise.then(finalResponse => { + expect(standardFetchResponse.json).toHaveBeenCalled() + expect(global.fetch).toHaveBeenCalledWith(secondaryLookupUrl, defaultFetchOptions) + + expect(finalResponse).toEqual({ + value: 'mockSecondaryResponse' + }) + }) + } + test('bulkDeleteModules returns expected output and calls other functions', () => { + return assertBulkDeleteModulesRunsWithOptions('/api/drafts') + }) + // three (plus one default) ways of calling createNewModule plus tutorial/normal module const assertCreateNewModuleRunsWithOptions = ( createUrl, @@ -339,6 +380,26 @@ describe('Dashboard Actions', () => { }) }) + test('selectModules returns the expected output', () => { + const actionReply = DashboardActions.selectModules(['mockDraftId1', 'mockDraftId2']) + + expect(global.fetch).not.toHaveBeenCalled() + expect(actionReply).toEqual({ + type: DashboardActions.SELECT_MODULES, + draftIds: ['mockDraftId1', 'mockDraftId2'] + }) + }) + + test('deselectModules returns the expected output', () => { + const actionReply = DashboardActions.deselectModules(['mockDraftId', 'mockDraftId2']) + + expect(global.fetch).not.toHaveBeenCalled() + expect(actionReply).toEqual({ + type: DashboardActions.DESELECT_MODULES, + draftIds: ['mockDraftId', 'mockDraftId2'] + }) + }) + test('showModuleMore returns the expected output', () => { const mockModule = { draftId: 'mockDraftId', diff --git a/packages/app/obojobo-repository/shared/components/__snapshots__/module.test.js.snap b/packages/app/obojobo-repository/shared/components/__snapshots__/module.test.js.snap index 0bd00d1227..30a658dda2 100644 --- a/packages/app/obojobo-repository/shared/components/__snapshots__/module.test.js.snap +++ b/packages/app/obojobo-repository/shared/components/__snapshots__/module.test.js.snap @@ -2,11 +2,17 @@ exports[`Module renders with expected standard props 1`] = `
+ +
+`; + exports[`Module renders with expected standard props but isNew=true 1`] = `
+ + +
+`; + +exports[`Module renders with expected standard props but isSelected=true 1`] = ` +
+ - - - - -
+ {props.multiSelectMode ? ( +
+ {getModuleCount(props.selectedModules)} + + +
+ ) : ( +
+ + + + + + +
+ )}
My Modules
@@ -179,11 +238,17 @@ const Dashboard = props => {
- {renderModules( - props.filteredModules ? props.filteredModules : props.myModules, - sortOrder, - newModuleId - )} + {moduleList.sort(getSortMethod(sortOrder)).map((draft, index) => ( + handleSelectModule(e, draft.draftId, index)} + key={draft.draftId} + hasMenu={true} + {...draft} + /> + ))}
diff --git a/packages/app/obojobo-repository/shared/components/dashboard.scss b/packages/app/obojobo-repository/shared/components/dashboard.scss index ec25830948..0f9367e9bf 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard.scss +++ b/packages/app/obojobo-repository/shared/components/dashboard.scss @@ -6,5 +6,26 @@ justify-content: space-between; align-items: center; margin-bottom: $size-spacing-vertical-big; + + &.is-multi-select-mode { + border: 1px solid $color-banner-bg; + background-color: $color-banner-bg; + border-radius: 0.15em; + + .module-count { + font-size: 0.8em; + margin-left: 1em; + width: 40em; + } + + button.secondary-button { + margin: 0.3em 0.2em; + font-size: 0.65em; + } + + button.close-button { + background: none; + } + } } } diff --git a/packages/app/obojobo-repository/shared/components/dashboard.test.js b/packages/app/obojobo-repository/shared/components/dashboard.test.js index 4668c5fe8e..aba20ed5ac 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard.test.js +++ b/packages/app/obojobo-repository/shared/components/dashboard.test.js @@ -18,6 +18,7 @@ import { create, act } from 'react-test-renderer' import Dashboard from './dashboard' import MultiButton from './multi-button' +import Button from './button' import Module from './module' import Search from './search' @@ -100,6 +101,8 @@ describe('Dashboard', () => { draftPermissions: {}, myCollections: [], myModules: [], + selectedModules: [], + multiSelectMode: false, sortOrder: 'alphabetical', moduleCount: 0, moduleSearchString: '', @@ -144,7 +147,8 @@ describe('Dashboard', () => { //numerous changes to check for within the main content area const mainContent = component.root.findByProps({ className: 'repository--main-content' }) //some in the control bar - const expectedControlBarClasses = 'repository--main-content--control-bar' + const expectedControlBarClasses = + 'repository--main-content--control-bar is-not-multi-select-mode' const controlBar = component.root.findByProps({ className: expectedControlBarClasses }) expect(controlBar.children.length).toBe(2) @@ -203,6 +207,32 @@ describe('Dashboard', () => { // Shouldn't be any modal dialogs open, either expect(component.root.findAllByType(ReactModal).length).toBe(0) + + component.unmount() + } + + const expectMultiSelectDashboardRender = () => { + dashboardProps.myModules = [...standardMyModules] + dashboardProps.multiSelectMode = true + const reusableComponent = + let component + act(() => { + component = create(reusableComponent) + }) + + const expectedControlBarClasses = 'repository--main-content--control-bar is-multi-select-mode' + const controlBar = component.root.findByProps({ className: expectedControlBarClasses }) + + expect(controlBar.children.length).toBe(3) + expect(component.root.findAllByType(Search).length).toBe(0) + + expectMultiSelectOptions(controlBar) + + const moduleComponents = component.root.findAllByType(Module) + expect(moduleComponents.length).toBe(5) + expect(moduleComponents[0].props.isMultiSelectMode).toBe(true) + + component.unmount() } const expectNormalModulesAreaClassesWithTitle = (mainContent, title) => { @@ -220,6 +250,12 @@ describe('Dashboard', () => { expect(multiButton.children[2].children[0].children[0]).toBe('Upload...') } + const expectMultiSelectOptions = controlBar => { + expect(controlBar.children[0].props.className).toBe('module-count') + expect(controlBar.children[1].children[0].children[0]).toBe('Delete All') + expect(controlBar.children[2].children[0].children[0]).toBe('×') + } + const expectCookiePropForPath = (prop, value, path) => { expect(document.cookie[prop].value).toBe(value) expect(document.cookie[prop].path).toBe(path) @@ -241,6 +277,10 @@ describe('Dashboard', () => { expectDashboardRender() }) + test('renders with multiSelectMode=true', () => { + expectMultiSelectDashboardRender() + }) + test('renders filtered modules properly', () => { dashboardProps.myModules = [...standardMyModules] dashboardProps.filterModules = jest.fn() @@ -289,6 +329,8 @@ describe('Dashboard', () => { expect(moduleComponents.length).toBe(2) expect(moduleComponents[0].props.draftId).toBe('mockDraftId4') expect(moduleComponents[1].props.draftId).toBe('mockDraftId3') + + component.unmount() }) test('"New Module" and "Upload..." buttons call functions appropriately', async () => { @@ -345,6 +387,162 @@ describe('Dashboard', () => { }) expect(dashboardProps.importModuleFile).toHaveBeenCalledTimes(1) dashboardProps.importModuleFile.mockReset() + + component.unmount() + }) + + test('"Delete All" button calls functions appropriately', async () => { + dashboardProps.bulkDeleteModules = jest.fn() + dashboardProps.selectedModules = ['mockId', 'mockId2'] + dashboardProps.multiSelectMode = true + const reusableComponent = + let component + act(() => { + component = create(reusableComponent) + }) + + const mockClickEvent = { + preventDefault: jest.fn() + } + + const deleteAllButton = component.root.findAllByType(Button)[0] + expect(deleteAllButton.children[0].children[0]).toBe('Delete All') + + window.prompt = jest.fn() + window.prompt.mockReturnValueOnce('NOT DELETE') + await act(async () => { + deleteAllButton.props.onClick(mockClickEvent) + }) + expect(dashboardProps.bulkDeleteModules).not.toHaveBeenCalled() + + window.prompt.mockReturnValueOnce('DELETE') + await act(async () => { + deleteAllButton.props.onClick(mockClickEvent) + }) + expect(dashboardProps.bulkDeleteModules).toHaveBeenCalledTimes(1) + + component.unmount() + }) + + test('"Deselect All" button calls functions appropriately', () => { + dashboardProps.deselectModules = jest.fn() + dashboardProps.selectedModules = ['mockId', 'mockId2'] + dashboardProps.multiSelectMode = true + const reusableComponent = + let component + act(() => { + component = create(reusableComponent) + }) + + const deselectAllButton = component.root.findAllByType(Button)[1] + expect(deselectAllButton.children[0].children[0]).toBe('×') + + act(() => { + deselectAllButton.props.onClick() + }) + expect(dashboardProps.deselectModules).toHaveBeenCalledWith(['mockId', 'mockId2']) + + component.unmount() + }) + + test('pressing Esc when multiSelectMode=true calls functions appropriately', () => { + dashboardProps.deselectModules = jest.fn() + let component + act(() => { + component = create() + }) + + // eslint-disable-next-line no-undef + document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })) + expect(dashboardProps.deselectModules).toHaveBeenCalledTimes(0) + + dashboardProps.multiSelectMode = true + act(() => { + component.update() + }) + + // eslint-disable-next-line no-undef + document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' })) + expect(dashboardProps.deselectModules).toHaveBeenCalledTimes(1) + + component.unmount() + }) + + test('selecting module calls functions appropriately', () => { + dashboardProps.myModules = [...standardMyModules] + dashboardProps.selectModules = jest.fn() + dashboardProps.deselectModules = jest.fn() + let component + act(() => { + component = create() + }) + + const moduleComponents = component.root.findAllByType(Module) + expect(moduleComponents[0].props.isSelected).toBe(false) + + act(() => { + const mockClickEvent = { + shiftKey: false + } + moduleComponents[0].props.onSelect(mockClickEvent) + }) + expect(dashboardProps.selectModules).toHaveBeenCalledTimes(1) + expect(dashboardProps.selectModules).toHaveBeenCalledWith(['mockDraftId2']) + + dashboardProps.selectedModules = ['mockDraftId2'] + dashboardProps.multiSelectMode = true + + act(() => { + component.update() + }) + expect(moduleComponents[0].props.isSelected).toBe(true) + + act(() => { + const mockClickEvent = { + shiftKey: false + } + moduleComponents[0].props.onSelect(mockClickEvent) + }) + expect(dashboardProps.deselectModules).toHaveBeenCalledTimes(1) + expect(dashboardProps.deselectModules).toHaveBeenCalledWith(['mockDraftId2']) + + component.unmount() + }) + + test('selecting modules with shift calls functions appropriately', () => { + dashboardProps.myModules = [...standardMyModules] + dashboardProps.selectModules = jest.fn() + let component + act(() => { + component = create() + }) + + const moduleComponents = component.root.findAllByType(Module) + + act(() => { + const mockClickEvent = { + shiftKey: true + } + moduleComponents[2].props.onSelect(mockClickEvent) + }) + expect(dashboardProps.selectModules).toHaveBeenCalledTimes(1) + expect(dashboardProps.selectModules).toHaveBeenCalledWith([ + 'mockDraftId2', + 'mockDraftId4', + 'mockDraftId3' + ]) + dashboardProps.selectModules.mockReset() + + act(() => { + const mockClickEvent = { + shiftKey: true + } + moduleComponents[1].props.onSelect(mockClickEvent) + }) + expect(dashboardProps.selectModules).toHaveBeenCalledTimes(1) + expect(dashboardProps.selectModules).toHaveBeenCalledWith(['mockDraftId4', 'mockDraftId3']) + + component.unmount() }) test('renders "Module Options" dialog', () => { @@ -367,6 +565,8 @@ describe('Dashboard', () => { dialogComponent.props.onClose() expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + + component.unmount() }) test('renders "Module Access" dialog', () => { @@ -389,6 +589,8 @@ describe('Dashboard', () => { dialogComponent.props.onClose() expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + + component.unmount() }) test('renders "Version History" dialog and runs callbacks properly', () => { @@ -410,6 +612,8 @@ describe('Dashboard', () => { dialogComponent.props.onClose() expect(dashboardProps.closeModal).toHaveBeenCalledTimes(1) + + component.unmount() }) test('renders no dialogs if props.dialog value is unsupported', () => { @@ -420,5 +624,7 @@ describe('Dashboard', () => { }) expect(component.root.findAllByType(ReactModal).length).toBe(0) + + component.unmount() }) }) diff --git a/packages/app/obojobo-repository/shared/components/module.jsx b/packages/app/obojobo-repository/shared/components/module.jsx index 500524aecc..3467e5352a 100644 --- a/packages/app/obojobo-repository/shared/components/module.jsx +++ b/packages/app/obojobo-repository/shared/components/module.jsx @@ -9,10 +9,19 @@ const Module = props => { let timeOutId const [isMenuOpen, setMenuOpen] = useState(false) const onCloseMenu = () => setMenuOpen(false) - const onToggleMenu = e => { - setMenuOpen(!isMenuOpen) + const handleClick = e => { + if (props.isMultiSelectMode || e.shiftKey || e.metaKey) { + onSelectModule(e) + } else { + setMenuOpen(!isMenuOpen) + } + e.preventDefault() // block the event from bubbling out to the parent href } + const onSelectModule = e => { + onCloseMenu() + props.onSelect(e) + } // Handle keyboard focus const onBlurHandler = () => { timeOutId = setTimeout(() => { @@ -29,19 +38,33 @@ const Module = props => { className={ 'repository--module-icon ' + (isMenuOpen ? 'is-open ' : 'is-not-open ') + + (props.isSelected ? 'is-selected ' : 'is-not-selected ') + (props.isNew ? 'is-new' : 'is-not-new') } onBlur={onBlurHandler} onFocus={onFocusHandler} > + {props.hasMenu ? ( - ) : ( diff --git a/packages/app/obojobo-repository/shared/components/module.scss b/packages/app/obojobo-repository/shared/components/module.scss index 1e2cd432b8..95f4ce5274 100644 --- a/packages/app/obojobo-repository/shared/components/module.scss +++ b/packages/app/obojobo-repository/shared/components/module.scss @@ -33,8 +33,25 @@ } } - &.is-open { + input { + position: absolute; + display: none; + z-index: 1; + left: 0.3em; + top: 0.3em; + + &.is-multi-select-mode { + display: block; + } + } + + &.is-open, + &.is-selected { background-color: $color-banner-bg; + + input { + display: block; + } } &.is-new { diff --git a/packages/app/obojobo-repository/shared/components/module.test.js b/packages/app/obojobo-repository/shared/components/module.test.js index ff95e87012..5afca949e2 100644 --- a/packages/app/obojobo-repository/shared/components/module.test.js +++ b/packages/app/obojobo-repository/shared/components/module.test.js @@ -19,7 +19,9 @@ describe('Module', () => { draftId: 'mockDraftId', title: 'Mock Module Title', hasMenu: true, - isNew: false + isNew: false, + isSelected: false, + isMultiSelectMode: false } }) @@ -29,10 +31,15 @@ describe('Module', () => { expect(component.root.findAllByType(ModuleMenu).length).toBe(0) expect(component.root.children[0].props.className).toBe( - 'repository--module-icon is-not-open is-not-new' + 'repository--module-icon is-not-open is-not-selected is-not-new' ) - const mainChildComponent = component.root.children[0].children[0] + const checkboxComponent = component.root.children[0].children[0] + expect(checkboxComponent.type).toBe('input') + expect(checkboxComponent.props.checked).toBe(false) + expect(checkboxComponent.props.className).toBe('is-not-multi-select-mode') + + const mainChildComponent = component.root.children[0].children[1] expect(mainChildComponent.type).toBe('button') expect(component.toJSON()).toMatchSnapshot() @@ -45,10 +52,15 @@ describe('Module', () => { expect(component.root.findAllByType(ModuleMenu).length).toBe(0) expect(component.root.children[0].props.className).toBe( - 'repository--module-icon is-not-open is-not-new' + 'repository--module-icon is-not-open is-not-selected is-not-new' ) - const mainChildComponent = component.root.children[0].children[0] + const checkboxComponent = component.root.children[0].children[0] + expect(checkboxComponent.type).toBe('input') + expect(checkboxComponent.props.checked).toBe(false) + expect(checkboxComponent.props.className).toBe('is-not-multi-select-mode') + + const mainChildComponent = component.root.children[0].children[1] expect(mainChildComponent.type).toBe('a') expect(mainChildComponent.props.href).toBe('/library/mockDraftId') @@ -62,10 +74,57 @@ describe('Module', () => { expect(component.root.findAllByType(ModuleMenu).length).toBe(0) expect(component.root.children[0].props.className).toBe( - 'repository--module-icon is-not-open is-new' + 'repository--module-icon is-not-open is-not-selected is-new' + ) + + const checkboxComponent = component.root.children[0].children[0] + expect(checkboxComponent.type).toBe('input') + expect(checkboxComponent.props.checked).toBe(false) + expect(checkboxComponent.props.className).toBe('is-not-multi-select-mode') + + const mainChildComponent = component.root.children[0].children[1] + expect(mainChildComponent.type).toBe('button') + + expect(component.toJSON()).toMatchSnapshot() + }) + + test('renders with expected standard props but isSelected=true', () => { + defaultProps.isSelected = true + const component = create() + + expect(component.root.findAllByType(ModuleMenu).length).toBe(0) + + expect(component.root.children[0].props.className).toBe( + 'repository--module-icon is-not-open is-selected is-not-new' ) - const mainChildComponent = component.root.children[0].children[0] + const checkboxComponent = component.root.children[0].children[0] + expect(checkboxComponent.type).toBe('input') + expect(checkboxComponent.props.checked).toBe(true) + expect(checkboxComponent.props.className).toBe('is-not-multi-select-mode') + + const mainChildComponent = component.root.children[0].children[1] + expect(mainChildComponent.type).toBe('button') + + expect(component.toJSON()).toMatchSnapshot() + }) + + test('renders with expected standard props but isMultiSelectMode=true', () => { + defaultProps.isMultiSelectMode = true + const component = create() + + expect(component.root.findAllByType(ModuleMenu).length).toBe(0) + + expect(component.root.children[0].props.className).toBe( + 'repository--module-icon is-not-open is-not-selected is-not-new' + ) + + const checkboxComponent = component.root.children[0].children[0] + expect(checkboxComponent.type).toBe('input') + expect(checkboxComponent.props.checked).toBe(false) + expect(checkboxComponent.props.className).toBe('is-multi-select-mode') + + const mainChildComponent = component.root.children[0].children[1] expect(mainChildComponent.type).toBe('button') expect(component.toJSON()).toMatchSnapshot() @@ -84,13 +143,48 @@ describe('Module', () => { preventDefault: jest.fn() } act(() => { - component.root.children[0].children[0].props.onClick(mockClickEvent) + component.root.children[0].children[1].props.onClick(mockClickEvent) component.update(reusableComponent) }) expect(mockClickEvent.preventDefault).toHaveBeenCalledTimes(1) expect(component.root.findAllByType(ModuleMenu).length).toBe(1) }) + test('clicking the main child component when isMultiSelectMode=true selects the module', () => { + defaultProps.isMultiSelectMode = true + defaultProps.onSelect = jest.fn() + const reusableComponent = + let component + act(() => { + component = create(reusableComponent) + }) + + const mockClickEvent = { + preventDefault: jest.fn() + } + act(() => { + component.root.children[0].children[1].props.onClick(mockClickEvent) + component.update(reusableComponent) + }) + expect(mockClickEvent.preventDefault).toHaveBeenCalledTimes(1) + expect(defaultProps.onSelect).toHaveBeenCalledTimes(1) + }) + + test('clicking the input component selects the module', () => { + defaultProps.onSelect = jest.fn() + const reusableComponent = + let component + act(() => { + component = create(reusableComponent) + }) + + act(() => { + component.root.children[0].children[0].props.onClick() + component.update(reusableComponent) + }) + expect(defaultProps.onSelect).toHaveBeenCalledTimes(1) + }) + test('the module menu is not rendered after onMouseLeave is called', () => { const reusableComponent = let component @@ -104,7 +198,7 @@ describe('Module', () => { const mockClickEvent = { preventDefault: jest.fn() } - component.root.children[0].children[0].props.onClick(mockClickEvent) + component.root.children[0].children[1].props.onClick(mockClickEvent) component.update(reusableComponent) }) expect(component.root.findAllByType(ModuleMenu).length).toBe(1) @@ -137,7 +231,7 @@ describe('Module', () => { const mockClickEvent = { preventDefault: jest.fn() } - component.root.children[0].children[0].props.onClick(mockClickEvent) + component.root.children[0].children[1].props.onClick(mockClickEvent) component.update(reusableComponent) }) expect(component.root.findAllByType(ModuleMenu).length).toBe(1) diff --git a/packages/app/obojobo-repository/shared/components/pages/page-dashboard-server.jsx b/packages/app/obojobo-repository/shared/components/pages/page-dashboard-server.jsx index 951d720f3a..f7cba4ff84 100644 --- a/packages/app/obojobo-repository/shared/components/pages/page-dashboard-server.jsx +++ b/packages/app/obojobo-repository/shared/components/pages/page-dashboard-server.jsx @@ -22,6 +22,8 @@ PageDashboardServer.defaultProps = { selectedModule: {}, draftPermissions: {}, myModules: [], + selectedModules: [], + multiSelectMode: false, moduleSearchString: '', shareSearchString: '', versionHistory: { diff --git a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js index 865a81fd93..b6fd4db597 100644 --- a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js +++ b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js @@ -11,8 +11,11 @@ const { CLEAR_PEOPLE_SEARCH_RESULTS, DELETE_MODULE_PERMISSIONS, DELETE_MODULE, + BULK_DELETE_MODULES, CREATE_NEW_MODULE, FILTER_MODULES, + SELECT_MODULES, + DESELECT_MODULES, SHOW_MODULE_MORE, SHOW_VERSION_HISTORY, RESTORE_VERSION @@ -69,6 +72,21 @@ function DashboardReducer(state, action) { } }) + case BULK_DELETE_MODULES: + return handle(state, action, { + // update myModules, re-apply the filter, and exit multi-select mode + success: prevState => { + const filteredModules = filterModules(action.payload.value, state.moduleSearchString) + return { + ...prevState, + myModules: action.payload.value, + filteredModules, + selectedModules: [], + multiSelectMode: false + } + } + }) + case SHOW_MODULE_MORE: return { ...state, @@ -97,6 +115,20 @@ function DashboardReducer(state, action) { moduleSearchString: action.searchString } + case SELECT_MODULES: + return { + ...state, + selectedModules: [...state.selectedModules, ...action.draftIds], + multiSelectMode: true + } + + case DESELECT_MODULES: + return { + ...state, + selectedModules: state.selectedModules.filter(m => !action.draftIds.includes(m)), + multiSelectMode: state.selectedModules.length !== action.draftIds.length + } + case CLEAR_PEOPLE_SEARCH_RESULTS: return { ...state, searchPeople: searchPeopleResultsState(), shareSearchString: '' } diff --git a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.test.js b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.test.js index 2c3047664c..dea6f8be9f 100644 --- a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.test.js +++ b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.test.js @@ -16,8 +16,11 @@ const { CLEAR_PEOPLE_SEARCH_RESULTS, DELETE_MODULE_PERMISSIONS, DELETE_MODULE, + BULK_DELETE_MODULES, CREATE_NEW_MODULE, FILTER_MODULES, + SELECT_MODULES, + DESELECT_MODULES, SHOW_MODULE_MORE, SHOW_VERSION_HISTORY, RESTORE_VERSION @@ -192,6 +195,53 @@ describe('Dashboard Reducer', () => { runCreateOrDeleteModuleActionTest(DELETE_MODULE) }) + test('BULK_DELETE_MODULES action modifies state correctly', () => { + const mockModuleList = [ + { + draftId: 'mockDraftId', + title: 'A Mock Module' + }, + { + draftId: 'mockDraftId2', + title: 'B Mock Module' + } + ] + + const initialState = { + multiSelectMode: true, + moduleSearchString: '', + selectedModules: ['mockDraftId', 'mockDraftId3'], + myModules: [ + { + draftId: 'oldMockDraftId', + title: 'Old Mock Module' + } + ], + filteredModules: [ + { + draftId: 'oldMockDraftId', + title: 'Old Mock Module' + } + ] + } + + const action = { + type: BULK_DELETE_MODULES, + payload: { + value: mockModuleList + } + } + + const handler = dashboardReducer(initialState, action) + + const newState = handleSuccess(handler) + expect(newState.myModules).not.toEqual(initialState.myModules) + expect(newState.myModules).toEqual(mockModuleList) + expect(newState.filteredModules).toEqual(mockModuleList) + expect(newState.selectedModules).toEqual([]) + expect(newState.multiSelectMode).toBe(false) + }) + test('SHOW_MODULE_MORE action modifies state correctly', () => { const initialState = { dialog: null, @@ -289,6 +339,68 @@ describe('Dashboard Reducer', () => { expect(newState.moduleSearchString).toBe('B') }) + test('SELECT_MODULES action modifies state correctly', () => { + const initialState = { + multiSelectMode: false, + myModules: [ + { + draftId: 'mockDraftId', + title: 'A Mock Module' + }, + { + draftId: 'mockDraftId2', + title: 'B Mock Module' + }, + { + draftId: 'mockDraftId3', + title: 'C Mock Module' + } + ], + selectedModules: [] + } + const action = { + type: SELECT_MODULES, + draftIds: ['mockDraftId', 'mockDraftId3'] + } + + // SELECT_MODULES is a synchronous action - state changes immediately + const newState = dashboardReducer(initialState, action) + expect(newState.myModules).toEqual(initialState.myModules) + expect(newState.selectedModules).toEqual(['mockDraftId', 'mockDraftId3']) + expect(newState.multiSelectMode).toBe(true) + }) + + test('DESELECT_MODULES action modifies state correctly', () => { + const initialState = { + multiSelectMode: true, + myModules: [ + { + draftId: 'mockDraftId', + title: 'A Mock Module' + }, + { + draftId: 'mockDraftId2', + title: 'B Mock Module' + }, + { + draftId: 'mockDraftId3', + title: 'C Mock Module' + } + ], + selectedModules: ['mockDraftId', 'mockDraftId3'] + } + const action = { + type: DESELECT_MODULES, + draftIds: ['mockDraftId'] + } + + // DESELECT_MODULES is a synchronous action - state changes immediately + const newState = dashboardReducer(initialState, action) + expect(newState.myModules).toEqual(initialState.myModules) + expect(newState.selectedModules).toEqual(['mockDraftId3']) + expect(newState.multiSelectMode).toBe(true) + }) + test('CLEAR_PEOPLE_SEARCH_RESULTS action modifies state correctly', () => { const initialState = { shareSearchString: 'oldSearchString',