diff --git a/.all-contributorsrc b/.all-contributorsrc index 9fcf951ff8..3673b0ce0a 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -5,14 +5,61 @@ "imageSize": 100, "commit": false, "contributors": [ + { + "login": "deundrewilliams", + "name": "deundrewilliams", + "avatar_url": "https://avatars.githubusercontent.com/u/41072160?v=4", + "profile": "https://github.com/deundrewilliams", + "contributions": [ + "code", + "review", + "test" + ] + }, + { + "login": "SJJacques", + "name": "Steve Jacques", + "avatar_url": "https://avatars.githubusercontent.com/u/71739913?v=4", + "profile": "https://github.com/SJJacques", + "contributions": [ + "code", + "test", + "review", + "bug" + ] + }, + { + "login": "walid-i", + "name": "walid-i", + "avatar_url": "https://avatars.githubusercontent.com/u/57739844?v=4", + "profile": "https://github.com/walid-i", + "contributions": [ + "code", + "test" + ] + }, + { + "login": "jpeterson976", + "name": "Jacob Peterson", + "avatar_url": "https://avatars.githubusercontent.com/u/46502440?v=4", + "profile": "https://github.com/jpeterson976", + "contributions": [ + "code", + "test", + "review", + "bug" + ] + }, { "login": "maufcost", - "name": "Mauricio Figueiredo", + "name": "Mauricio Costa", "avatar_url": "https://avatars1.githubusercontent.com/u/39862359?v=4", "profile": "https://github.com/maufcost", "contributions": [ "code", - "test" + "test", + "review", + "bug" ] }, { @@ -117,9 +164,14 @@ "avatar_url": "https://avatars1.githubusercontent.com/u/1275983?v=4", "profile": "https://github.com/FrenjaminBanklin", "contributions": [ - "code", "bug", + "code", + "doc", + "ideas", + "maintenance", + "projectManagement", "test", + "tool", "review" ] }, @@ -182,11 +234,14 @@ "profile": "https://ianturgeon.com", "contributions": [ "code", + "doc", "test", "ideas", + "infra", "platform", "projectManagement", "maintenance", + "review", "tool" ] }, @@ -202,7 +257,8 @@ "design", "ideas", "projectManagement", - "review" + "review", + "tool" ] } ], @@ -211,5 +267,6 @@ "projectOwner": "ucfopen", "repoType": "github", "repoHost": "https://github.com", - "skipCi": true + "skipCi": true, + "commitConvention": "none" } diff --git a/.github/stale.yml b/.github/stale.yml index 91f307615b..5c9f14ba17 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -2,7 +2,7 @@ daysUntilStale: 180 # Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 +daysUntilClose: false # Issues with these labels will never be considered stale exemptLabels: diff --git a/README.md b/README.md index c56a4fe6f6..d2d0c34f02 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Obojobo Next -[![All Contributors](https://img.shields.io/badge/all_contributors-17-orange.svg?style=flat-square)](#contributors-) +[![All Contributors](https://img.shields.io/badge/all_contributors-21-orange.svg?style=flat-square)](#contributors-) Obojobo Next is a modern educational content ecosystem. Obojobo documents are programmable, extend-able, and heavily fortified with data analytics. @@ -79,32 +79,37 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d - - - - - - - + + + + + + + - - - - - - - + + + + + + + - - - + + + + + + +

Mauricio Figueiredo

💻 ⚠️

Toan Vu

💻 🤔 👀 ⚠️

Cameron Cuff

💻 🤔 👀 ⚠️

AnthonyRodriguez726

💻 👀 ⚠️

Ralph Baird

💻 👀 ⚠️

Corey Peterson

💻

Sid

💻 ⚠️

deundrewilliams

💻 👀 ⚠️

Steve Jacques

💻 ⚠️ 👀 🐛

walid-i

💻 ⚠️

Jacob Peterson

💻 ⚠️ 👀 🐛

Mauricio Costa

💻 ⚠️ 👀 🐛

Toan Vu

💻 🤔 👀 ⚠️

Cameron Cuff

💻 🤔 👀 ⚠️

Keegan Berry

💻 ⚠️

Adrian Fish

💻 🐛 🤔

Brandon Stull

💻 🐛 ⚠️ 👀

Shea

📖

Samuel Belcastro

💻 ⚠️

Ryan Eppers

🐛 💻 📖 🤔 🚧 📦 💬 👀 🔧

Jonathan Guilbe

💻 ⚠️

AnthonyRodriguez726

💻 👀 ⚠️

Ralph Baird

💻 👀 ⚠️

Corey Peterson

💻

Sid

💻 ⚠️

Jonathan Guilbe

💻 ⚠️

Keegan Berry

💻 ⚠️

Adrian Fish

💻 🐛 🤔

Elli Howard

️️️️♿️ 💻 📖 🤔 🚧 👀 ⚠️

Zachary Berry

💻 🖋 📖 🎨 🤔 📆 👀

Ian Turgeon

⚠️ 💻

Brandon Stull

🐛 💻 📖 🤔 🚧 📆 ⚠️ 🔧 👀

Shea

📖

Samuel Belcastro

💻 ⚠️

Ryan Eppers

🐛 💻 📖 🤔 🚧 📦 💬 👀 🔧 👀

Elli Howard

️️️️♿️ 💻 📖 🤔 🚧 👀 ⚠️

Ian Turgeon

💻 📖 ⚠️ 🤔 🚇 📦 📆 🚧 👀 🔧

Zachary Berry

💻 🖋 📖 🎨 🤔 📆 👀 🔧
- + + This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! diff --git a/docker/obojobo-pm2-server-src/package.json b/docker/obojobo-pm2-server-src/package.json index c74d6123d7..7bc3fcb949 100644 --- a/docker/obojobo-pm2-server-src/package.json +++ b/docker/obojobo-pm2-server-src/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-pm2-server-app", - "version": "14.0.0", + "version": "15.0.0", "description": "Reference project for deploying and customizing an Obojobo Next server", "main": "./index.js", "private": true, diff --git a/lerna.json b/lerna.json index ad7abff9a1..6789707102 100644 --- a/lerna.json +++ b/lerna.json @@ -2,7 +2,7 @@ "packages": [ "packages/**/*" ], - "version": "14.0.0", + "version": "15.0.0", "command": { "command": { "run": { diff --git a/package.json b/package.json index fbaba3e41f..925cda9f6e 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test": "TZ='America/New_York' jest --verbose", "test:ci": "TZ='America/New_York' CI=true jest --ci --useStderr --coverage --coverageReporters text-summary cobertura", "test:ci:each": "lerna run test:ci", + "test:dev": "TZ='America/New_York' jest --verbose --watchAll --coverage --coverageReporters lcov", "postinstall": "husky install" }, "devDependencies": { diff --git a/packages/app/obojobo-document-engine/__tests__/Viewer/stores/__snapshots__/nav-store.test.js.snap b/packages/app/obojobo-document-engine/__tests__/Viewer/stores/__snapshots__/nav-store.test.js.snap index ce7b538987..b8d2a1ddd1 100644 --- a/packages/app/obojobo-document-engine/__tests__/Viewer/stores/__snapshots__/nav-store.test.js.snap +++ b/packages/app/obojobo-document-engine/__tests__/Viewer/stores/__snapshots__/nav-store.test.js.snap @@ -385,7 +385,7 @@ Array [ ] `; -exports[`NavStore nav:goto does not change page when locked 1`] = `undefined`; +exports[`NavStore nav:goto does not change page when locked if ignoreLock is false 1`] = `undefined`; exports[`NavStore nav:gotoPath event calls gotoItem and postEvent 1`] = ` Array [ diff --git a/packages/app/obojobo-document-engine/__tests__/Viewer/stores/nav-store.test.js b/packages/app/obojobo-document-engine/__tests__/Viewer/stores/nav-store.test.js index 5cb95d5c8a..1b1ba9e1db 100644 --- a/packages/app/obojobo-document-engine/__tests__/Viewer/stores/nav-store.test.js +++ b/packages/app/obojobo-document-engine/__tests__/Viewer/stores/nav-store.test.js @@ -366,17 +366,94 @@ describe('NavStore', () => { expect(Dispatcher.trigger).not.toHaveBeenCalled() }) - test('nav:goto does not change page when locked', () => { + test('nav:goto does nothing if payload is undefined', () => { NavStore.setState({ isInitialized: true, - navTargetId: 7, + navTargetId: 'mockId', + itemsById: { + mockId: { id: 'mockId', flags: {} } + } + }) + + expect(Dispatcher.trigger).not.toHaveBeenCalled() + eventCallbacks['nav:goto']() + expect(Dispatcher.trigger).not.toHaveBeenCalled() + }) + + test('nav:goto does nothing if payload.value is undefined', () => { + NavStore.setState({ + isInitialized: true, + navTargetId: 'mockId', + itemsById: { + mockId: { id: 'mockId', flags: {} } + } + }) + + expect(Dispatcher.trigger).not.toHaveBeenCalled() + eventCallbacks['nav:goto']({}) + expect(Dispatcher.trigger).not.toHaveBeenCalled() + }) + + test('nav:goto still changes page when locked if ignoreLock is not provided', () => { + NavStore.setState({ + isInitialized: true, + navTargetId: 'mockId', + navTargetHistory: [], + itemsById: { + mockId: { id: 'mockId', flags: {} } + }, locked: true }) const spy = jest.spyOn(NavStore, 'gotoItem') + NavStore.gotoItem.mockReturnValueOnce(true) // go - eventCallbacks['nav:goto']() + eventCallbacks['nav:goto']({ value: { id: 'mockId' } }) + expect(NavStore.gotoItem).toHaveBeenCalledTimes(1) + expect(NavStore.gotoItem).toHaveBeenCalledWith({ flags: {}, id: 'mockId' }) + expect(ViewerAPI.postEvent).toHaveBeenCalledTimes(1) + + spy.mockRestore() + }) + + test('nav:goto still changes page when locked if ignoreLock is true', () => { + NavStore.setState({ + isInitialized: true, + navTargetId: 'mockId', + navTargetHistory: [], + itemsById: { + mockId: { id: 'mockId', flags: {} } + }, + locked: true + }) + + const spy = jest.spyOn(NavStore, 'gotoItem') + NavStore.gotoItem.mockReturnValueOnce(true) + + // go + eventCallbacks['nav:goto']({ value: { id: 'mockId', ignoreLock: true } }) + expect(NavStore.gotoItem).toHaveBeenCalledTimes(1) + expect(NavStore.gotoItem).toHaveBeenCalledWith({ flags: {}, id: 'mockId' }) + expect(ViewerAPI.postEvent).toHaveBeenCalledTimes(1) + + spy.mockRestore() + }) + + test('nav:goto does not change page when locked if ignoreLock is false', () => { + NavStore.setState({ + isInitialized: true, + navTargetId: 'mockId', + itemsById: { + mockId: { id: 'mockId', flags: {} } + }, + locked: true + }) + + const spy = jest.spyOn(NavStore, 'gotoItem') + + // go + eventCallbacks['nav:goto']({ value: { id: 'mockId', ignoreLock: false } }) expect(NavStore.gotoItem).not.toHaveBeenCalled() expect(ViewerAPI.postEvent).not.toHaveBeenCalled() expect(ViewerAPI.postEvent.mock.calls[0]).toMatchSnapshot() diff --git a/packages/app/obojobo-document-engine/__tests__/Viewer/util/nav-util.test.js b/packages/app/obojobo-document-engine/__tests__/Viewer/util/nav-util.test.js index 11b3811a8c..9aa514c714 100644 --- a/packages/app/obojobo-document-engine/__tests__/Viewer/util/nav-util.test.js +++ b/packages/app/obojobo-document-engine/__tests__/Viewer/util/nav-util.test.js @@ -106,12 +106,26 @@ describe('NavUtil', () => { expect(x).toBe('mockTriggerReturn') }) - test('goto', () => { + test('goto (ignoreLock not provided)', () => { expect(Common.flux.Dispatcher.trigger).not.toHaveBeenCalled() const x = NavUtil.goto('mockId') const expectedValue = { value: { - id: 'mockId' + id: 'mockId', + ignoreLock: true + } + } + expect(Common.flux.Dispatcher.trigger).toHaveBeenCalledWith('nav:goto', expectedValue) + expect(x).toBe('mockTriggerReturn') + }) + + test('goto (ignoreLock provided)', () => { + expect(Common.flux.Dispatcher.trigger).not.toHaveBeenCalled() + const x = NavUtil.goto('mockId', false) + const expectedValue = { + value: { + id: 'mockId', + ignoreLock: false } } expect(Common.flux.Dispatcher.trigger).toHaveBeenCalledWith('nav:goto', expectedValue) diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/__snapshots__/visual-editor.test.js.snap b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/__snapshots__/visual-editor.test.js.snap index d5cbb7e57c..8d774d8e8b 100644 --- a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/__snapshots__/visual-editor.test.js.snap +++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/__snapshots__/visual-editor.test.js.snap @@ -836,6 +836,23 @@ Ctrl+Shift+R" /> + + + + +
" diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/toolbars/format-menu.test.js b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/toolbars/format-menu.test.js index e857772767..52beadec85 100644 --- a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/toolbars/format-menu.test.js +++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/toolbars/format-menu.test.js @@ -118,7 +118,7 @@ describe('FormatMenu', () => { component .find('button') - .at(25) + .at(26) .simulate('click') expect(useEditor().unindentList).toHaveBeenCalled() diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/triggers/__snapshots__/trigger-list-modal.test.js.snap b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/triggers/__snapshots__/trigger-list-modal.test.js.snap index b958697529..8c5b5754b8 100644 --- a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/triggers/__snapshots__/trigger-list-modal.test.js.snap +++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/triggers/__snapshots__/trigger-list-modal.test.js.snap @@ -1,14 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`TriggerListModal adds a trigger 1`] = `"

Triggers

"`; +exports[`TriggerListModal adds a trigger 1`] = `"

Triggers

Ignore Navigation Lock
Ignore Navigation Lock
"`; -exports[`TriggerListModal adds an action 1`] = `"

Triggers

"`; +exports[`TriggerListModal adds an action 1`] = `"

Triggers

Ignore Navigation Lock
Ignore Navigation Lock
"`; exports[`TriggerListModal changes action type 1`] = `"

Triggers

"`; -exports[`TriggerListModal changes action value 1`] = `"

Triggers

"`; +exports[`TriggerListModal changes action value 1`] = `"

Triggers

Ignore Navigation Lock
Ignore Navigation Lock
"`; -exports[`TriggerListModal changes trigger 1`] = `"

Triggers

"`; +exports[`TriggerListModal changes trigger 1`] = `"

Triggers

Ignore Navigation Lock
"`; exports[`TriggerListModal createNewDefaultActionValueObject(assessment:endAttempt) creates a new default action value object 1`] = ` Object { @@ -36,6 +36,7 @@ exports[`TriggerListModal createNewDefaultActionValueObject(nav:close) creates a exports[`TriggerListModal createNewDefaultActionValueObject(nav:goto) creates a new default action value object 1`] = ` Object { "id": "", + "ignoreLock": true, } `; @@ -74,6 +75,6 @@ exports[`TriggerListModal deletes an action 1`] = `"

Triggers

"`; -exports[`TriggerListModal renders all options 1`] = `"

Triggers

Animate Scroll
Fade Out Other Items
"`; +exports[`TriggerListModal renders all options 1`] = `"

Triggers

Ignore Navigation Lock
Animate Scroll
Fade Out Other Items
"`; exports[`TriggerListModal renders if given no triggers 1`] = `"

Triggers

"`; diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/triggers/trigger-list-modal.test.js b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/triggers/trigger-list-modal.test.js index 0c83193044..5a41c20cd4 100644 --- a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/triggers/trigger-list-modal.test.js +++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/triggers/trigger-list-modal.test.js @@ -247,14 +247,14 @@ describe('TriggerListModal', () => { // change the value component .find('input') - .at(2) + .at(3) .simulate('change', { target: { type: 'text', value: '10' } }) // check that the value changed expect( component .find('input') - .at(2) + .at(3) .props() ).toHaveProperty('value', '10') @@ -270,6 +270,54 @@ describe('TriggerListModal', () => { expect(tree).toMatchSnapshot() }) + test('sets value that was previously undefined', () => { + const content = { + triggers: [ + { + type: 'onMount', + actions: [{ type: 'nav:goto', value: { id: 1 } }] + }, + { + type: 'onUnmount', + actions: [] + } + ] + } + const component = mount() + + // make sure this is the expected label/input combo + const inputLabel = component.find('label').at(2) + expect(inputLabel.props().children).toBe('Item Id') + + expect( + component + .find('input') + .at(2) + .props() + ).toHaveProperty('checked', true) + + // change the value + component + .find('input') + .at(2) + .simulate('change', { target: { type: 'boolean', value: false } }) + + // check that the value changed + expect( + component + .find('input') + .at(2) + .props() + ).toHaveProperty('checked', false) + + // check the change to state + expect(component.state()).toHaveProperty('triggers') + expect(component.state().triggers[0].actions).toContainEqual({ + type: 'nav:goto', + value: { id: 1, ignoreLock: false } + }) + }) + test('changes scroll type', () => { const content = { triggers: [ diff --git a/packages/app/obojobo-document-engine/package.json b/packages/app/obojobo-document-engine/package.json index 3ced94b4ce..f0fbb697d2 100644 --- a/packages/app/obojobo-document-engine/package.json +++ b/packages/app/obojobo-document-engine/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-document-engine", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "", "engines": { diff --git a/packages/app/obojobo-document-engine/src/scripts/common/components/modal/settings-dialog.scss b/packages/app/obojobo-document-engine/src/scripts/common/components/modal/settings-dialog.scss index 04a706e273..eeefae9c47 100644 --- a/packages/app/obojobo-document-engine/src/scripts/common/components/modal/settings-dialog.scss +++ b/packages/app/obojobo-document-engine/src/scripts/common/components/modal/settings-dialog.scss @@ -3,7 +3,6 @@ .obojobo-draft--components--modal--settings-dialog { text-align: left; min-width: 20em; - width: 40vw; display: flex; flex-flow: column wrap; diff --git a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/marks/align-marks.js b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/marks/align-marks.js index 753c6b539e..44483898d9 100644 --- a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/marks/align-marks.js +++ b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/marks/align-marks.js @@ -4,10 +4,12 @@ import { ReactEditor } from 'slate-react' import LeftIcon from '../../assets/left-icon' import RightIcon from '../../assets/right-icon' import CenterIcon from '../../assets/center-icon' +import JustifyIcon from '../../assets/justify-icon' const ALIGN_RIGHT = 'right' const ALIGN_CENTER = 'center' const ALIGN_LEFT = 'left' +const ALIGN_JUSTIFY = 'justify' const AlignMarks = { plugins: { @@ -25,6 +27,9 @@ const AlignMarks = { case 'e': event.preventDefault() return editor.setAlign(ALIGN_CENTER) + case 'j': + event.preventDefault() + return editor.setAlign(ALIGN_JUSTIFY) } }, commands: { @@ -65,6 +70,13 @@ const AlignMarks = { type: ALIGN_RIGHT, icon: RightIcon, action: editor => editor.setAlign(ALIGN_RIGHT) + }, + { + name: 'Justify', + shortcut: 'Shift+J', + type: ALIGN_JUSTIFY, + icon: JustifyIcon, + action: editor => editor.setAlign(ALIGN_JUSTIFY) } ] } diff --git a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/navigation/editor-nav.scss b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/navigation/editor-nav.scss index 180434affd..c09ac7aa29 100644 --- a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/navigation/editor-nav.scss +++ b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/navigation/editor-nav.scss @@ -8,7 +8,7 @@ height: inherit; display: table-cell; - z-index: $z-index-above-content; + z-index: $z-index-above-most; font-family: $font-default; color: $color-text; background: $color-nav-bg; diff --git a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/triggers/trigger-list-modal.js b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/triggers/trigger-list-modal.js index 11738f0ce0..3c3f4880a6 100644 --- a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/triggers/trigger-list-modal.js +++ b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/triggers/trigger-list-modal.js @@ -59,6 +59,11 @@ class TriggerListModal extends React.Component { createNewDefaultActionValueObject(type) { switch (type) { case 'nav:goto': + return { + id: '', + ignoreLock: true + } + case 'assessment:startAttempt': case 'assessment:endAttempt': return { @@ -230,6 +235,17 @@ class TriggerListModal extends React.Component { value={action.value.id || ''} onChange={this.updateActionValue.bind(this, triggerIndex, actionIndex, 'id')} /> + ) diff --git a/packages/app/obojobo-document-engine/src/scripts/viewer/stores/nav-store.js b/packages/app/obojobo-document-engine/src/scripts/viewer/stores/nav-store.js index 85a58645ec..341035ccba 100644 --- a/packages/app/obojobo-document-engine/src/scripts/viewer/stores/nav-store.js +++ b/packages/app/obojobo-document-engine/src/scripts/viewer/stores/nav-store.js @@ -126,6 +126,19 @@ class NavStore extends Store { } }, 'nav:goto': payload => { + /* eslint-disable no-undefined */ + if ( + payload === undefined || + payload.value === undefined || + payload.value.id === undefined + ) { + return + } + if (payload.value.ignoreLock === undefined) payload.value.ignoreLock = true + /* eslint-enable no-undefined */ + + if (this.state.locked && !payload.value.ignoreLock) return + if (!this.state.isInitialized) { this.pendingTarget = { type: 'goto', @@ -135,8 +148,6 @@ class NavStore extends Store { return } - if (this.state.locked) return - oldNavTargetId = this.state.navTargetId const navItem = this.state.itemsById[payload.value.id] diff --git a/packages/app/obojobo-document-engine/src/scripts/viewer/util/nav-util.js b/packages/app/obojobo-document-engine/src/scripts/viewer/util/nav-util.js index bebbfdbb8e..fd0ef28363 100644 --- a/packages/app/obojobo-document-engine/src/scripts/viewer/util/nav-util.js +++ b/packages/app/obojobo-document-engine/src/scripts/viewer/util/nav-util.js @@ -60,10 +60,13 @@ const NavUtil = { return Dispatcher.trigger('nav:next') }, - goto(id) { + goto(id, ignoreLock) { + // eslint-disable-next-line no-undefined + if (ignoreLock === undefined) ignoreLock = true return Dispatcher.trigger('nav:goto', { value: { - id + id, + ignoreLock } }) }, diff --git a/packages/app/obojobo-document-engine/src/scripts/viewer/util/stop-viewer.js b/packages/app/obojobo-document-engine/src/scripts/viewer/util/stop-viewer.js index d6a024cb98..858fe17305 100644 --- a/packages/app/obojobo-document-engine/src/scripts/viewer/util/stop-viewer.js +++ b/packages/app/obojobo-document-engine/src/scripts/viewer/util/stop-viewer.js @@ -16,7 +16,7 @@ export const stopViewer = () => {

This Obojobo module window has expired. Typically this is caused by opening this module in - more then one window. + more than one window.

, true @@ -35,7 +35,6 @@ const executeHeartBeat = draftId => { if (result.status !== 'ok') { stopViewer() } - sysend.broadcast('viewer-init', { windowId, draftId }) }) } diff --git a/packages/app/obojobo-document-json-parser/package.json b/packages/app/obojobo-document-json-parser/package.json index 5afd224c29..4eb60db2a2 100644 --- a/packages/app/obojobo-document-json-parser/package.json +++ b/packages/app/obojobo-document-json-parser/package.json @@ -3,11 +3,11 @@ "xml-formatter": "^2.4.0" }, "peerDependencies": { - "obojobo-document-engine": "^14.0.0", - "obojobo-lib-utils": "^14.0.0" + "obojobo-document-engine": "^15.0.0", + "obojobo-lib-utils": "^15.0.0" }, "name": "obojobo-document-json-parser", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "main": "", "scripts": { diff --git a/packages/app/obojobo-document-xml-parser/package.json b/packages/app/obojobo-document-xml-parser/package.json index db20ae3957..68558ea7e8 100644 --- a/packages/app/obojobo-document-xml-parser/package.json +++ b/packages/app/obojobo-document-xml-parser/package.json @@ -4,10 +4,10 @@ "xml-js": "^1.0.2" }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "name": "obojobo-document-xml-parser", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "main": "xml2draft.js", "scripts": { diff --git a/packages/app/obojobo-express/__tests__/routes/api/drafts.test.js b/packages/app/obojobo-express/__tests__/routes/api/drafts.test.js index bab6d540f0..434a050944 100644 --- a/packages/app/obojobo-express/__tests__/routes/api/drafts.test.js +++ b/packages/app/obojobo-express/__tests__/routes/api/drafts.test.js @@ -1,3 +1,4 @@ +jest.mock('obojobo-repository/server/models/collection') jest.mock('../../../server/models/draft') jest.mock('../../../server/models/user') jest.mock('../../../server/db') @@ -7,6 +8,7 @@ jest.mock('obojobo-document-json-parser/json-to-xml-parser') jest.mock('obojobo-repository/server/models/draft_permissions') import DraftModel from '../../../server/models/draft' +const CollectionModel = require('obojobo-repository/server/models/collection') const xml = require('obojobo-document-xml-parser/xml-to-draft-object') const jsonToXml = require('obojobo-document-json-parser/json-to-xml-parser') const DraftPermissions = require('obojobo-repository/server/models/draft_permissions') @@ -21,13 +23,13 @@ const app = express() const basicXML = ` - - -

Hello World!

-
-
-
-
` + + +

Hello World!

+
+
+ +` const mockInsertNewDraft = mockVirtual('./server/routes/api/drafts/insert_new_draft') const db = oboRequire('server/db') @@ -67,6 +69,7 @@ describe('api draft route', () => { jsonToXml.mockReset() DraftPermissions.userHasPermissionToDraft.mockReset() DraftPermissions.userHasPermissionToDraft.mockResolvedValue(true) + CollectionModel.addModule.mockReset() }) afterEach(() => {}) @@ -517,7 +520,12 @@ describe('api draft route', () => { return request(app) .post('/api/drafts/new') - .send({ content: 'mockContent', format: 'application/json' }) + .send({ + moduleContent: { + content: 'mockContent', + format: 'application/json' + } + }) .then(response => { expect(response.header['content-type']).toContain('application/json') expect(response.statusCode).toBe(200) @@ -535,8 +543,10 @@ describe('api draft route', () => { return request(app) .post('/api/drafts/new') .send({ - content: 'mockContent', - format: 'application/xml' + moduleContent: { + content: 'mockContent', + format: 'application/xml' + } }) .then(response => { expect(response.header['content-type']).toContain('application/json') @@ -556,7 +566,12 @@ describe('api draft route', () => { return request(app) .post('/api/drafts/new') .accept('text/plain') - .send({ content: 'mockCont222ent', format: 'application/xml' }) + .send({ + moduleContent: { + content: 'mockCont222ent', + format: 'application/xml' + } + }) .then(response => { expect(response.header['content-type']).toContain('application/json') expect(response.statusCode).toBe(500) @@ -576,7 +591,12 @@ describe('api draft route', () => { return request(app) .post('/api/drafts/new') .accept('text/plain') - .send({ content: 'mockCont222ent', format: 'application/xml' }) + .send({ + moduleContent: { + content: 'mockCont222ent', + format: 'application/xml' + } + }) .then(response => { expect(response.header['content-type']).toContain('application/json') expect(response.statusCode).toBe(500) @@ -615,6 +635,61 @@ describe('api draft route', () => { }) }) + //when the request body has a 'collectionId' corresponding to a collection + // owned by the current user + test('new draft is automatically added to a specified collection', () => { + expect.hasAssertions() + + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(true) + + mockCurrentUser = { id: 99, hasPermission: perm => perm === 'canCreateDrafts' } // mock current logged in user + return request(app) + .post('/api/drafts/new') + .send({ collectionId: 'mockCollectionId' }) + .then(async response => { + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledWith( + 99, + 'mockCollectionId' + ) + expect(CollectionModel.addModule).toHaveBeenCalledWith( + 'mockCollectionId', + 'mockDraftId', + 99 + ) + + expect(response.header['content-type']).toContain('application/json') + expect(response.statusCode).toBe(200) + expect(response.body).toHaveProperty('status', 'ok') + expect(response.body).toHaveProperty('value.id', 'mockDraftId') + expect(response.body).toHaveProperty('value.contentId', 'mockContentId') + }) + }) + + //when the request body has a 'collectionId' corresponding to a collection + // not owned by the current user + test('new draft is not created if specified collection is not owned by user', () => { + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(false) + + expect.assertions(7) + mockCurrentUser = { id: 99, hasPermission: perm => perm === 'canCreateDrafts' } // mock current logged in user + return request(app) + .post('/api/drafts/new') + .send({ collectionId: 'mockCollectionId' }) + .then(async response => { + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledWith( + 99, + 'mockCollectionId' + ) + expect(CollectionModel.addModule).not.toHaveBeenCalled() + + expect(response.header['content-type']).toContain('application/json') + expect(response.statusCode).toBe(401) + expect(response.body).toHaveProperty('status', 'error') + expect(response.body).toHaveProperty('value') + expect(response.body.value).toHaveProperty('type', 'notAuthorized') + }) + }) + // new tutorial test('new tutorial returns success', () => { @@ -631,6 +706,45 @@ describe('api draft route', () => { }) }) + test('new tutorial returns success when added to a collection', () => { + expect.hasAssertions() + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(true) + CollectionModel.addModule.mockResolvedValueOnce(true) + mockCurrentUser = { id: 99, hasPermission: perm => perm === 'canCreateDrafts' } // mock current logged in user + return request(app) + .post('/api/drafts/tutorial') + .type('application/json') + .send('{"collectionId":55}') + .then(response => { + expect(response.header['content-type']).toContain('application/json') + expect(response.statusCode).toBe(200) + expect(response).toHaveProperty('body.status', 'ok') + expect(response).toHaveProperty('body.value.id', 'mockDraftId') + expect(response).toHaveProperty('body.value.contentId', 'mockContentId') + expect(response).toHaveProperty('body.value.collectionId', 55) + }) + }) + + test('new tutorial returns error when user does not have perms to collection', () => { + expect.hasAssertions() + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(false) + CollectionModel.addModule.mockResolvedValueOnce(true) + mockCurrentUser = { id: 99, hasPermission: perm => perm === 'canCreateDrafts' } // mock current logged in user + return request(app) + .post('/api/drafts/tutorial') + .type('application/json') + .send('{"collectionId":55}') + .then(response => { + expect(response.header['content-type']).toContain('application/json') + expect(response.statusCode).toBe(401) + expect(response).toHaveProperty('body.status', 'error') + expect(response).toHaveProperty( + 'body.value.message', + 'You must have permissions to the requested collection to add a new module to it.' + ) + }) + }) + test('new tutorial requires a login', () => { expect.assertions(5) mockCurrentUser = { id: 99, hasPermission: () => false } // mock current logged in user @@ -868,8 +982,27 @@ describe('api draft route', () => { }) }) - // restore draft + test('delete 401s when a user tries deleting a draft they do not own', () => { + expect.assertions(5) + + DraftPermissions.userHasPermissionToDraft.mockResolvedValueOnce(false) + mockCurrentUser = { id: 99, hasPermission: perm => perm === 'canDeleteDrafts' } // mock current logged in user + + return request(app) + .delete('/api/drafts/00000000-0000-0000-0000-000000000000') + .then(response => { + expect(response.header['content-type']).toContain('application/json') + expect(response.statusCode).toBe(401) + expect(response.body).toHaveProperty('status', 'error') + expect(response.body.value).toHaveProperty('type', 'notAuthorized') + expect(response.body.value).toHaveProperty( + 'message', + 'You must be the author of this draft to delete it' + ) + }) + }) + // restore draft test('restore draft returns successfully', () => { expect.assertions(4) DraftModel.restoreByIdAndUser.mockResolvedValueOnce('mock-db-result') diff --git a/packages/app/obojobo-express/package.json b/packages/app/obojobo-express/package.json index f2e15f552d..dbb7b8c258 100644 --- a/packages/app/obojobo-express/package.json +++ b/packages/app/obojobo-express/package.json @@ -1,7 +1,7 @@ { "name": "obojobo-express", "license": "AGPL-3.0-only", - "version": "14.0.0", + "version": "15.0.0", "repository": "https://github.com/ucfopen/Obojobo.git", "homepage": "https://ucfopen.github.io/Obojobo-Docs/", "description": "Obojobo express server middleware.", @@ -64,14 +64,14 @@ "pg-promise": "^10.10.1", "react-transition-group": "^4.4.1", "serve-favicon": "~2.5.0", - "sharp": "^0.28.1", + "sharp": "^0.30.5", "trianglify": "^4.1.1", "uuid": "^8.3.2" }, "peerDependencies": { - "obojobo-document-engine": "^14.0.0", - "obojobo-document-xml-parser": "^14.0.0", - "obojobo-lib-utils": "^14.0.0" + "obojobo-document-engine": "^15.0.0", + "obojobo-document-xml-parser": "^15.0.0", + "obojobo-lib-utils": "^15.0.0" }, "devDependencies": { "@svgr/webpack": "^5.5.0", @@ -81,7 +81,7 @@ "css-loader": "^5.2.0", "express-list-endpoints": "^5.0.0", "mini-css-extract-plugin": "^1.4.0", - "node-sass": "^5.0.0", + "node-sass": "^7.0.0", "oauth-signature": "^1.5.0", "postcss-loader": "^5.2.0", "sass-loader": "^11.0.1", diff --git a/packages/app/obojobo-express/server/migrations/20210910201141-remove-caliper-table.js b/packages/app/obojobo-express/server/migrations/20210910201141-remove-caliper-table.js index e5574b403c..d0716288db 100644 --- a/packages/app/obojobo-express/server/migrations/20210910201141-remove-caliper-table.js +++ b/packages/app/obojobo-express/server/migrations/20210910201141-remove-caliper-table.js @@ -28,9 +28,9 @@ exports.down = function(db) { defaultValue: new String('now()') }, payload: { type: 'json', notNull: true }, - is_preview: { type: Boolean } + is_preview: { type: 'boolean' } }) - .then(result => { + .then(() => { return db.addIndex('caliper_store', 'caliper_store_created_at_index', ['created_at']) }) .then(() => { diff --git a/packages/app/obojobo-express/server/obo_express_dev.js b/packages/app/obojobo-express/server/obo_express_dev.js index 9b77708b05..c608054d65 100644 --- a/packages/app/obojobo-express/server/obo_express_dev.js +++ b/packages/app/obojobo-express/server/obo_express_dev.js @@ -273,6 +273,18 @@ module.exports = app => {
+ + +
+ + +
+ + +
+ + +
` : '' } @@ -352,9 +364,17 @@ module.exports = app => { resource_link_id, score_import: req.query.score_import === 'on' ? 'true' : 'false' } + const launchContext = { ...ltiContext } + + if (req.query.context_id) launchContext.context_id = req.query.context_id + if (req.query.context_label) launchContext.context_label = req.query.context_label + if (req.query.context_title) launchContext.context_title = req.query.context_title + if (req.query.resource_link_title) { + launchContext.resource_link_title = req.query.resource_link_title + } renderLtiLaunch( - { ...ltiContext, ...person, ...params }, + { ...launchContext, ...person, ...params }, 'POST', `${baseUrl(req)}/view/${draftId}`, res diff --git a/packages/app/obojobo-express/server/routes/api/drafts.js b/packages/app/obojobo-express/server/routes/api/drafts.js index 0e99a142be..01f72723a3 100644 --- a/packages/app/obojobo-express/server/routes/api/drafts.js +++ b/packages/app/obojobo-express/server/routes/api/drafts.js @@ -1,6 +1,7 @@ const express = require('express') const fs = require('fs') const router = express.Router() +const CollectionModel = require('obojobo-repository/server/models/collection') const DraftModel = oboRequire('server/models/draft') const logger = oboRequire('server/logger') const pgp = require('pg-promise') @@ -107,9 +108,21 @@ router router .route('/new') .post(requireCanCreateDrafts) - .post((req, res, next) => { - const content = req.body.content - const format = req.body.format + .post(async (req, res, next) => { + if (req.body.collectionId) { + const hasPermsToCollection = await DraftPermissions.userHasPermissionToCollection( + req.currentUser.id, + req.body.collectionId + ) + if (!hasPermsToCollection) { + return res.notAuthorized( + 'You must have permissions to the requested collection to add a new module to it.' + ) + } + } + + const content = req.body.moduleContent ? req.body.moduleContent.content : null + const format = req.body.moduleContent ? req.body.moduleContent.format : null let draftJson = !format ? draftTemplate : null let draftXml = !format ? draftTemplateXML : null @@ -132,25 +145,53 @@ router } } - return DraftModel.createWithContent(req.currentUser.id, draftJson, draftXml) - .then(draft => { - res.set('Obo-DraftContentId', draft.content.id) - res.success({ id: draft.id, contentId: draft.content.id }) + try { + const newDraft = await DraftModel.createWithContent(req.currentUser.id, draftJson, draftXml) + if (req.body.collectionId) { + await CollectionModel.addModule(req.body.collectionId, newDraft.id, req.currentUser.id) + } + res.set('Obo-DraftContentId', newDraft.content.id) + res.success({ + id: newDraft.id, + contentId: newDraft.content.id, + collectionId: req.body.collectionId }) - .catch(res.unexpected) + } catch (error) { + res.unexpected(error) + } }) + // Create an editable tutorial document // mounted as /api/drafts/tutorial router .route('/tutorial') .post(requireCanCreateDrafts) - .post((req, res) => { - return DraftModel.createWithContent(req.currentUser.id, tutorialDraft) - .then(draft => { - res.set('Obo-DraftContentId', draft.content.id) - res.success({ id: draft.id, contentId: draft.content.id }) + .post(async (req, res) => { + try { + if (req.body.collectionId) { + const hasPermsToCollection = await DraftPermissions.userHasPermissionToCollection( + req.currentUser.id, + req.body.collectionId + ) + if (!hasPermsToCollection) { + return res.notAuthorized( + 'You must have permissions to the requested collection to add a new module to it.' + ) + } + } + const newDraft = await DraftModel.createWithContent(req.currentUser.id, tutorialDraft) + if (req.body.collectionId) { + await CollectionModel.addModule(req.body.collectionId, newDraft.id, req.currentUser.id) + } + res.set('Obo-DraftContentId', newDraft.content.id) + res.success({ + id: newDraft.id, + contentId: newDraft.content.id, + collectionId: req.body.collectionId }) - .catch(res.unexpected) + } catch (error) { + res.unexpected(error) + } }) // Update a Draft @@ -217,7 +258,16 @@ router router .route('/:draftId') .delete([requireCanDeleteDrafts, requireDraftId, checkValidationRules]) - .delete((req, res) => { + .delete(async (req, res) => { + const hasPerms = await DraftPermissions.userHasPermissionToDraft( + req.currentUser.id, + req.params.draftId + ) + + if (!hasPerms) { + return res.notAuthorized('You must be the author of this draft to delete it') + } + return DraftModel.deleteByIdAndUser(req.params.draftId, req.currentUser.id) .then(res.success) .catch(res.unexpected) diff --git a/packages/app/obojobo-module-selector/client/js/module-selector.js b/packages/app/obojobo-module-selector/client/js/module-selector.js index 7bf55b6f8c..054f9abef2 100644 --- a/packages/app/obojobo-module-selector/client/js/module-selector.js +++ b/packages/app/obojobo-module-selector/client/js/module-selector.js @@ -310,7 +310,10 @@ import '../css/module-selector.scss' .then(respJson => { if (respJson.status !== 'ok') throw 'Failed loading modules' - data.allItems = data.items = respJson.value + // personal module lookup has an extra layer indicating total module count + data.allItems = data.items = respJson.value.modules + ? respJson.value.modules + : respJson.value populateSection(section, title, color) if (searchEl.value !== '') { diff --git a/packages/app/obojobo-module-selector/package.json b/packages/app/obojobo-module-selector/package.json index e3311845dc..e1c4ce193b 100644 --- a/packages/app/obojobo-module-selector/package.json +++ b/packages/app/obojobo-module-selector/package.json @@ -1,7 +1,7 @@ { "name": "obojobo-module-selector", "license": "AGPL-3.0-only", - "version": "14.0.0", + "version": "15.0.0", "repository": "https://github.com/ucfopen/Obojobo.git", "homepage": "https://ucfopen.github.io/Obojobo-Docs/", "description": "Obojobo package responsible for selecting which module you use in a course.", @@ -29,8 +29,8 @@ "express": "~4.17.1" }, "peerDependencies": { - "obojobo-express": "^14.0.0", - "obojobo-lib-utils": "^14.0.0", - "obojobo-repository": "^14.0.0" + "obojobo-express": "^15.0.0", + "obojobo-lib-utils": "^15.0.0", + "obojobo-repository": "^15.0.0" } } diff --git a/packages/app/obojobo-repository/client/css/_defaults.scss b/packages/app/obojobo-repository/client/css/_defaults.scss index 8ed225d844..5e22b291a2 100644 --- a/packages/app/obojobo-repository/client/css/_defaults.scss +++ b/packages/app/obojobo-repository/client/css/_defaults.scss @@ -13,6 +13,7 @@ $color-text: #000000; $color-text-minor: lighten($color-text, 40%); $color-text-subheading: #a20e83; $color-bg: #ffffff; +$color-bg-overlay: rgba(200, 200, 200, 0.9); $color-shadow: rgba(0, 0, 0, 0.3); $color-highlight: #93c6ff; $color-bg2: #f4f4f4; @@ -50,6 +51,7 @@ $font-text: 'Noto Serif', serif; $font-monospace: 'Roboto Mono', monospace; $font-size: 14.25pt; +$dimension-collection-icon: 90px; $dimension-module-icon: 90px; $dimension-avatar-icon: 90px; $dimension-padding: 1.5em; diff --git a/packages/app/obojobo-repository/package.json b/packages/app/obojobo-repository/package.json index 5b0b5a56c8..3b7b78a41e 100644 --- a/packages/app/obojobo-repository/package.json +++ b/packages/app/obojobo-repository/package.json @@ -1,7 +1,7 @@ { "name": "obojobo-repository", "license": "AGPL-3.0-only", - "version": "14.0.0", + "version": "15.0.0", "repository": "https://github.com/ucfopen/Obojobo.git", "homepage": "https://ucfopen.github.io/Obojobo-Docs/", "description": "Obojobo express server middleware.", @@ -41,12 +41,13 @@ "redux-pack": "^0.1.5", "sass-mq": "^5.0.1", "seedrandom": "^2.3.11", + "short-uuid": "^3.1.1", "styled-components": "^5.3.0", "use-debounce": "^7.0.0" }, "peerDependencies": { - "obojobo-express": "^14.0.0", - "obojobo-lib-utils": "^14.0.0" + "obojobo-express": "^15.0.0", + "obojobo-lib-utils": "^15.0.0" }, "jest": { "setupFilesAfterEnv": [ diff --git a/packages/app/obojobo-repository/server/models/collection.js b/packages/app/obojobo-repository/server/models/collection.js index 5a2584a457..5f8e131ce2 100644 --- a/packages/app/obojobo-repository/server/models/collection.js +++ b/packages/app/obojobo-repository/server/models/collection.js @@ -2,13 +2,12 @@ const db = require('obojobo-express/server/db') const logger = require('obojobo-express/server/logger') const DraftSummary = require('./draft_summary') -class RepositoryCollection { - constructor({ id = null, title = '', user_id, created_at = null }) { +class Collection { + constructor({ id = null, title = '', user_id = null, created_at = null }) { this.id = id this.title = title this.userId = user_id this.createdAt = created_at - this.drafts = [] } static fetchById(id) { @@ -27,33 +26,97 @@ class RepositoryCollection { { id } ) .then(selectResult => { - return new RepositoryCollection(selectResult) + return new Collection(selectResult) }) .catch(error => { throw logger.logError('Collection fetchById Error', error) }) } - static create({ title = '', user_id }) { + static createWithUser(userId, title = 'New Collection') { return db .one( ` - INSERT INTO repository_collections - (title, user_id) + INSERT INTO repository_collections + (title, group_type, user_id, visibility_type) + VALUES + ($[title], 'tag', $[userId], 'private') + RETURNING *`, + { title, userId } + ) + .then(newCollection => { + logger.info('user created collection', { userId, collectionId: newCollection.id, title }) + return new Collection(newCollection) + }) + } + + static rename(id, newTitle, userId) { + return db + .one( + `UPDATE repository_collections + SET title = $[newTitle] + WHERE id = $[id] + RETURNING *`, + { id, newTitle } + ) + .then(updatedCollection => { + logger.info('collection renamed', { + userId, + id: updatedCollection.id, + title: updatedCollection.title + }) + return new Collection(updatedCollection) + }) + } + + static addModule(collectionId, draftId, userId) { + return db + .oneOrNone( + `INSERT INTO repository_map_drafts_to_collections + (draft_id, collection_id, user_id) VALUES - ($[title], $[user_id]) - RETURNING - id, - title, - user_id as userId, - created_at as createdAt`, - { - title, - user_id + ($[draftId], $[collectionId], $[userId]) + ON CONFLICT DO NOTHING + RETURNING id`, + { collectionId, draftId, userId } + ) + .then(newMapId => { + if (newMapId) { + logger.info('user added module to collection', { + userId, + collectionId, + draftId, + newMapId + }) } + }) + } + + static removeModule(collectionId, draftId, userId) { + return db + .none( + `DELETE FROM repository_map_drafts_to_collections + WHERE + draft_id = $[draftId] + AND collection_id = $[collectionId] + `, + { collectionId, draftId } + ) + .then(() => { + logger.info('user removed module from collection', { userId, collectionId, draftId }) + }) + } + + static delete(id, userId) { + return db + .none( + `UPDATE repository_collections + SET deleted = TRUE + WHERE id = $[id]`, + { id } ) - .then(insertResult => { - return new RepositoryCollection(insertResult) + .then(() => { + logger.info('collection deleted by user', { id, userId }) }) } @@ -77,4 +140,4 @@ class RepositoryCollection { } } -module.exports = RepositoryCollection +module.exports = Collection diff --git a/packages/app/obojobo-repository/server/models/collection.test.js b/packages/app/obojobo-repository/server/models/collection.test.js index 948719d804..d592e38dee 100644 --- a/packages/app/obojobo-repository/server/models/collection.test.js +++ b/packages/app/obojobo-repository/server/models/collection.test.js @@ -26,10 +26,10 @@ describe('Collection Model', () => { test('constructor initializes expected default properties', () => { const c = new CollectionModel({}) - expect(c.id).toBe(null) + expect(c.id).toBeNull() expect(c.title).toBe('') - expect(c.userId).toBeUndefined() - expect(c.createdAt).toBe(null) + expect(c.userId).toBeNull() + expect(c.createdAt).toBeNull() }) test('constructor initializes expected properties from provided object', () => { @@ -68,7 +68,7 @@ describe('Collection Model', () => { }) }) - test('create with no title returns a collection', () => { + test('createWithUser with no title returns a collection and logs its creation', () => { expect.hasAssertions() const userId = 1 const mockNewRawCollection = { @@ -80,41 +80,140 @@ describe('Collection Model', () => { db.one.mockResolvedValueOnce(mockNewRawCollection) - const mockCallObject = { - user_id: userId - } - - return CollectionModel.create(mockCallObject).then(model => { + return CollectionModel.createWithUser(userId).then(model => { expect(model).toBeInstanceOf(CollectionModel) expect(model.id).toBe('mockCollectionId') expect(model.title).toBe('mockCollectionTitle') expect(model.userId).toBe(userId) expect(model.createdAt).toBe(mockNewRawCollection.created_at) + expect(logger.info).toHaveBeenCalledWith('user created collection', { + userId, + collectionId: 'mockCollectionId', + //this is the default if no title is provided - despite the mocked DB response + title: 'New Collection' + }) }) }) - test('create calls db.one() correctly', () => { + test('createWithUser with title returns a collection and logs its creation', () => { + logger.info = jest.fn() + expect.hasAssertions() - const mockCallObject = { - title: 'mockCollectionTitle', - user_id: 1 + const userId = 1 + const mockNewRawCollection = { + id: 'mockCollectionId', + title: 'New Collection Title', + user_id: userId, + created_at: new Date().toISOString() } - db.one.mockResolvedValueOnce({}) - - const createQuery = ` - INSERT INTO repository_collections - (title, user_id) - VALUES - ($[title], $[user_id]) - RETURNING - id, - title, - user_id as userId, - created_at as createdAt` - - return CollectionModel.create(mockCallObject).then(() => { - expect(db.one).toHaveBeenCalledWith(createQuery, mockCallObject) + db.one.mockResolvedValueOnce(mockNewRawCollection) + + return CollectionModel.createWithUser(userId, 'New Collection Title').then(model => { + expect(model).toBeInstanceOf(CollectionModel) + expect(model.id).toBe('mockCollectionId') + expect(model.title).toBe('New Collection Title') + expect(model.userId).toBe(userId) + expect(model.createdAt).toBe(mockNewRawCollection.created_at) + expect(logger.info).toHaveBeenCalledWith('user created collection', { + userId, + collectionId: 'mockCollectionId', + title: model.title + }) + }) + }) + + test('rename returns a collection and logs user id, collection id and new title', () => { + logger.info = jest.fn() + + expect.hasAssertions() + + db.one.mockResolvedValueOnce({ ...mockRawCollection, title: 'mockCollectionTitle' }) + + const userId = 0 + + return CollectionModel.rename('mockCollectionId', 'mockCollectionTitle', userId).then(model => { + expect(model).toBeInstanceOf(CollectionModel) + expect(model.id).toBe('mockCollectionId') + expect(model.title).toBe('mockCollectionTitle') + expect(model.userId).toBe(0) + expect(model.createdAt).toBe(mockRawCollection.created_at) + expect(logger.info).toHaveBeenCalledWith('collection renamed', { + id: 'mockCollectionId', + title: 'mockCollectionTitle', + userId + }) + }) + }) + + test('addModule logs a user adding a module to a collection', () => { + logger.info = jest.fn() + + expect.hasAssertions() + + const collectionId = 'mockCollectionId' + const draftId = 'mockDraftId' + const userId = 0 + + const mockPayload = { collectionId, draftId, userId } + const mockResponse = 1 + + db.oneOrNone.mockResolvedValueOnce(mockResponse) + + return CollectionModel.addModule(collectionId, draftId, userId).then(() => { + expect(logger.info).toHaveBeenCalledWith('user added module to collection', { + ...mockPayload, + newMapId: mockResponse + }) + }) + }) + test('addModule logs nothing when trying to add a module to a collection that already contains that module', () => { + logger.info = jest.fn() + + expect.hasAssertions() + + const collectionId = 'mockCollectionId' + const draftId = 'mockDraftId' + const userId = 0 + + db.oneOrNone.mockResolvedValueOnce(null) + + return CollectionModel.addModule(collectionId, draftId, userId).then(() => { + expect(logger.info).not.toHaveBeenCalled() + }) + }) + + test('removeModule logs a user removing a module from a collection', () => { + logger.info = jest.fn() + + expect.hasAssertions() + + const collectionId = 'mockCollectionId' + const draftId = 'mockDraftId' + const userId = 0 + + const mockPayload = { collectionId, draftId, userId } + + db.none.mockResolvedValueOnce(mockPayload) + + return CollectionModel.removeModule(collectionId, draftId, userId).then(() => { + expect(logger.info).toHaveBeenCalledWith('user removed module from collection', mockPayload) + }) + }) + + test('delete logs a user deleting a collection', () => { + logger.info = jest.fn() + + expect.hasAssertions() + + const collectionId = 'mockCollectionId' + const userId = 0 + + return CollectionModel.delete(collectionId, userId).then(() => { + expect(logger.info).toHaveBeenCalledWith('collection deleted by user', { + id: collectionId, + userId + }) }) }) diff --git a/packages/app/obojobo-repository/server/models/draft_permissions.js b/packages/app/obojobo-repository/server/models/draft_permissions.js index 35e07acbca..bf2fda065a 100644 --- a/packages/app/obojobo-repository/server/models/draft_permissions.js +++ b/packages/app/obojobo-repository/server/models/draft_permissions.js @@ -107,6 +107,23 @@ class DraftPermissions { throw logger.logError('Error draftIsPublic', error) } } + + // returns a boolean + static async userHasPermissionToCollection(userId, collectionId) { + try { + const result = await db.oneOrNone( + `SELECT user_id + FROM repository_collections + WHERE id = $[collectionId] + AND user_id = $[userId]`, + { userId, collectionId } + ) + + return result !== null + } catch (error) { + throw logger.logError('Error userHasPermissionToCollection', error) + } + } } module.exports = DraftPermissions diff --git a/packages/app/obojobo-repository/server/models/draft_permissions.test.js b/packages/app/obojobo-repository/server/models/draft_permissions.test.js index c2dc2073f0..e03c306b2f 100644 --- a/packages/app/obojobo-repository/server/models/draft_permissions.test.js +++ b/packages/app/obojobo-repository/server/models/draft_permissions.test.js @@ -5,6 +5,18 @@ describe('DraftPermissions Model', () => { let db let logger let DraftPermissions + let mockError + + const mockUserResults = { + id: 1, + first_name: 'Jeffrey', + last_name: 'Lebowski', + email: 'dude@obojobo.com', + username: 'dude', + created_at: 'whevever', + roles: ['student'], + extras: 'test-value' + } beforeEach(() => { jest.resetModules() @@ -12,6 +24,9 @@ describe('DraftPermissions Model', () => { db = require('obojobo-express/server/db') logger = require('obojobo-express/server/logger') DraftPermissions = require('./draft_permissions') + mockError = new Error('mock-error') + // mock logError to return the error itself + logger.logError = jest.fn().mockImplementation((label, error) => error) }) test('DraftPermissions has expected methods', () => { @@ -39,9 +54,7 @@ describe('DraftPermissions Model', () => { test('addOwnerToDraft throws and logs error', () => { expect.hasAssertions() - const mockError = new Error('mock-error') db.none.mockRejectedValueOnce(mockError) - logger.logError = jest.fn().mockReturnValueOnce(mockError) return DraftPermissions.addOwnerToDraft('MDID', 'MUID').catch(error => { expect(logger.logError).toHaveBeenCalledWith('Error addOwnerToDraft', mockError) @@ -68,9 +81,7 @@ describe('DraftPermissions Model', () => { test('removeOwnerFromDraft throws and logs error', () => { expect.hasAssertions() - const mockError = new Error('mock-error') db.none.mockRejectedValueOnce(mockError) - logger.logError = jest.fn().mockReturnValueOnce(mockError) return DraftPermissions.removeOwnerFromDraft('MDID', 'MUID').catch(error => { expect(logger.logError).toHaveBeenCalledWith('Error removeOwnerFromDraft', mockError) @@ -81,16 +92,6 @@ describe('DraftPermissions Model', () => { test('getDraftOwners retrieves a data from the database', () => { expect.hasAssertions() - const mockUserResults = { - id: 1, - first_name: 'Jeffrey', - last_name: 'Lebowski', - email: 'dude@obojobo.com', - username: 'dude', - created_at: 'whevever', - roles: ['student'], - extras: 'test-value' - } db.manyOrNone.mockResolvedValueOnce([mockUserResults]) return DraftPermissions.getDraftOwners('MDID').then(users => { @@ -119,9 +120,7 @@ describe('DraftPermissions Model', () => { test('getDraftOwners throws and logs error', () => { expect.hasAssertions() - const mockError = new Error('mock-error') db.manyOrNone.mockRejectedValueOnce(mockError) - logger.logError = jest.fn().mockReturnValueOnce(mockError) return DraftPermissions.getDraftOwners('MDID', 'MUID').catch(error => { expect(logger.logError).toHaveBeenCalledWith('Error getDraftOwners', mockError) @@ -165,9 +164,7 @@ describe('DraftPermissions Model', () => { test('userHasPermissionToDraft throws and logs error', () => { expect.hasAssertions() - const mockError = new Error('mock-error') db.oneOrNone.mockRejectedValueOnce(mockError) - logger.logError = jest.fn().mockReturnValueOnce(mockError) return DraftPermissions.userHasPermissionToDraft('MUID', 'MDID').catch(error => { expect(logger.logError).toHaveBeenCalledWith('Error userHasPermissionToDraft', mockError) @@ -194,9 +191,7 @@ describe('DraftPermissions Model', () => { test('draftIsPublic throws and logs error', () => { expect.hasAssertions() - const mockError = new Error('mock-error') db.oneOrNone.mockRejectedValueOnce(mockError) - logger.logError = jest.fn().mockReturnValueOnce(mockError) return DraftPermissions.draftIsPublic('MUID', 'MDID').catch(error => { expect(logger.logError).toHaveBeenCalledWith('Error draftIsPublic', mockError) @@ -242,13 +237,8 @@ describe('DraftPermissions Model', () => { test('userHasPermissionToCopy throws and logs error', () => { expect.hasAssertions() - const mockError = new Error('mock-error') db.oneOrNone.mockResolvedValueOnce('mock-db-results') // draftIsPublic call db.oneOrNone.mockRejectedValueOnce(mockError) // userHasPermissionToDraft call - logger.logError = jest - .fn() - .mockReturnValueOnce(mockError) - .mockReturnValueOnce(mockError) return DraftPermissions.userHasPermissionToCopy('MUID', 'MDID').catch(error => { expect(logger.logError).toHaveBeenCalledWith('Error userHasPermissionToCopy', mockError) @@ -258,17 +248,59 @@ describe('DraftPermissions Model', () => { test('userHasPermissionToCopy throws and logs error', () => { expect.hasAssertions() - const mockError = new Error('mock-error') db.oneOrNone.mockRejectedValueOnce(mockError) // draftIsPublic call db.oneOrNone.mockResolvedValueOnce('mock-db-results') // userHasPermissionToDraft call - logger.logError = jest - .fn() - .mockReturnValueOnce(mockError) - .mockReturnValueOnce(mockError) return DraftPermissions.userHasPermissionToCopy('MUID', 'MDID').catch(error => { expect(logger.logError).toHaveBeenCalledWith('Error userHasPermissionToCopy', mockError) expect(error).toBe(mockError) }) }) + + test('userHasPermissionToCollection returns true when the user has permission', () => { + expect.hasAssertions() + db.oneOrNone.mockResolvedValueOnce(mockUserResults.id) + + return DraftPermissions.userHasPermissionToCollection( + mockUserResults.id, + 'mockCollectionId' + ).then(response => { + expect(db.oneOrNone).toHaveBeenCalledTimes(1) + expect(db.oneOrNone).toHaveBeenCalledWith(expect.any(String), { + userId: mockUserResults.id, + collectionId: 'mockCollectionId' + }) + expect(response).toBe(true) + }) + }) + + test('userHasPermissionToCollection returns false when the user does not have permission', () => { + expect.hasAssertions() + db.oneOrNone.mockResolvedValueOnce(null) + + return DraftPermissions.userHasPermissionToCollection( + mockUserResults.id, + 'mockCollectionId' + ).then(response => { + expect(db.oneOrNone).toHaveBeenCalledTimes(1) + expect(db.oneOrNone).toHaveBeenCalledWith(expect.any(String), { + userId: mockUserResults.id, + collectionId: 'mockCollectionId' + }) + expect(response).toBe(false) + }) + }) + + test('userHasPermissionToCollection handles error', () => { + expect.hasAssertions() + db.oneOrNone.mockRejectedValueOnce(mockError) + + return DraftPermissions.userHasPermissionToCollection( + mockUserResults.id, + 'mockCollectionId' + ).catch(error => { + expect(logger.logError).toHaveBeenCalledWith('Error userHasPermissionToCollection', mockError) + expect(error).toBe(mockError) + }) + }) }) diff --git a/packages/app/obojobo-repository/server/models/draft_summary.js b/packages/app/obojobo-repository/server/models/draft_summary.js index efb2f6aa81..abf49aa0ed 100644 --- a/packages/app/obojobo-repository/server/models/draft_summary.js +++ b/packages/app/obojobo-repository/server/models/draft_summary.js @@ -1,7 +1,7 @@ const db = require('obojobo-express/server/db') const logger = require('obojobo-express/server/logger') -const buildQueryWhere = (whereSQL, joinSQL = '', deleted = 'FALSE') => { +const buildQueryWhere = (whereSQL, joinSQL = '', deleted = 'FALSE', limitSQL = '') => { return ` SELECT DISTINCT drafts_content.draft_id AS draft_id, @@ -23,6 +23,7 @@ const buildQueryWhere = (whereSQL, joinSQL = '', deleted = 'FALSE') => { ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) ORDER BY updated_at DESC + ${limitSQL} ` } @@ -82,6 +83,81 @@ class DraftSummary { ) } + static fetchRecentByUserId(userId) { + return DraftSummary.fetchAndJoinWhereLimit( + `JOIN repository_map_user_to_draft + ON repository_map_user_to_draft.draft_id = drafts.id`, + `repository_map_user_to_draft.user_id = $[userId]`, + 'LIMIT 5', + { userId } + ) + } + + static fetchAllInCollection(collectionId) { + return DraftSummary.fetchAndJoinWhere( + `JOIN repository_map_drafts_to_collections + ON repository_map_drafts_to_collections.draft_id = drafts.id`, + `repository_map_drafts_to_collections.collection_id = $[collectionId]`, + { collectionId } + ) + } + + static fetchAllInCollectionForUser(collectionId, userId) { + return DraftSummary.fetchAndJoinWhere( + `JOIN repository_map_drafts_to_collections + ON repository_map_drafts_to_collections.draft_id = drafts.id + JOIN repository_map_user_to_draft + ON repository_map_user_to_draft.draft_id = drafts.id`, + `repository_map_drafts_to_collections.collection_id = $[collectionId] + AND repository_map_user_to_draft.user_id = $[userId]`, + { collectionId, userId } + ) + } + + static fetchByDraftTitleAndUser(searchString, userId) { + searchString = `%${searchString}%` + const whereSQL = `repository_map_user_to_draft.user_id = $[userId]` + + const joinSQL = `JOIN repository_map_user_to_draft + ON repository_map_user_to_draft.draft_id = drafts.id` + + const innerQuery = buildQueryWhere(whereSQL, joinSQL) + const query = ` + SELECT inner_query.* + FROM ( + ${innerQuery} + ) AS inner_query + WHERE inner_query.title ILIKE $[searchString] + ` + + const queryValues = { userId, searchString } + + return db + .any(query, queryValues) + .then(DraftSummary.resultsToObjects) + .catch(error => { + logger.error('fetchByDraftTitleAndUser Error', error.message, query, queryValues) + return Promise.reject('Error loading DraftSummary by query') + }) + } + + static fetchAndJoinWhereLimit(joinSQL, whereSQL, limitSQL, queryValues) { + return db + .any(buildQueryWhere(whereSQL, joinSQL, 'FALSE', limitSQL), queryValues) + .then(DraftSummary.resultsToObjects) + .catch(error => { + logger.error( + 'fetchAndJoinWhereLimit Error', + error.message, + joinSQL, + whereSQL, + limitSQL, + queryValues + ) + return Promise.reject('Error loading DraftSummary by query') + }) + } + static fetchDeletedByUserId(userId) { return DraftSummary.fetchAndJoinWhere( `JOIN repository_map_user_to_draft diff --git a/packages/app/obojobo-repository/server/models/draft_summary.test.js b/packages/app/obojobo-repository/server/models/draft_summary.test.js index 4134d0d035..b35aa5d864 100644 --- a/packages/app/obojobo-repository/server/models/draft_summary.test.js +++ b/packages/app/obojobo-repository/server/models/draft_summary.test.js @@ -66,6 +66,18 @@ describe('DraftSummary Model', () => { } ] + const checkAgainstMockRawSummary = summary => { + expect(summary).toBeInstanceOf(DraftSummary) + expect(summary.draftId).toBe('mockDraftId') + expect(summary.title).toBe('mockDraftTitle') + expect(summary.userId).toBe(0) + expect(summary.createdAt).toBe(mockRawDraftSummary.created_at) + expect(summary.updatedAt).toBe(mockRawDraftSummary.updated_at) + expect(summary.latestVersion).toBe('mockLatestVersionId') + expect(summary.revisionCount).toBe(1) + expect(summary.editor).toBe('visual') + } + beforeEach(() => { jest.resetModules() jest.resetAllMocks() @@ -76,10 +88,9 @@ describe('DraftSummary Model', () => { }) afterEach(() => {}) - //NOTE: // This is just the DraftSummary non-public 'buildQuery' method. // Not sure if there's a good way of exposing that, so will just use this. - const queryBuilder = (whereSQL, joinSQL = '', deleted = 'FALSE') => + const queryBuilder = (whereSQL, joinSQL = '', deleted = 'FALSE', limitSQL = '') => ` SELECT DISTINCT drafts_content.draft_id AS draft_id, @@ -101,6 +112,7 @@ describe('DraftSummary Model', () => { ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) ORDER BY updated_at DESC + ${limitSQL} ` const fetchAllDraftRevisionsQuery = ` @@ -178,10 +190,11 @@ describe('DraftSummary Model', () => { db.one = jest.fn() db.one.mockResolvedValueOnce(mockRawDraftSummary) - const query = queryBuilder('drafts.id = $[id]') - return DraftSummary.fetchById('mockDraftId').then(summary => { - expect(db.one).toHaveBeenCalledWith(query, { id: 'mockDraftId' }) + const query = queryBuilder('drafts.id = $[id]') + const [actualQuery, options] = db.one.mock.calls[0] + expectQueryToMatch(query, actualQuery) + expect(options).toEqual({ id: 'mockDraftId' }) expectIsMockSummary(summary) }) }) @@ -210,11 +223,129 @@ describe('DraftSummary Model', () => { const query = queryBuilder(whereSQL, joinSQL) return DraftSummary.fetchByUserId(0).then(summary => { - expect(db.any).toHaveBeenCalledWith(query, { userId: 0 }) - expectIsMockSummary(summary) + const [actualQuery, options] = db.any.mock.calls[0] + expectQueryToMatch(query, actualQuery) + expect(options).toEqual({ userId: 0 }) + checkAgainstMockRawSummary(summary) + }) + }) + + test('fetchRecentByUserId generates the correct query and returns a DraftSummary object', () => { + db.any = jest.fn() + db.any.mockResolvedValueOnce(mockRawDraftSummary) + + const whereSQL = 'repository_map_user_to_draft.user_id = $[userId]' + const joinSQL = `JOIN repository_map_user_to_draft + ON repository_map_user_to_draft.draft_id = drafts.id` + const limitSQL = 'LIMIT 5' + const query = queryBuilder(whereSQL, joinSQL, 'FALSE', limitSQL) + + return DraftSummary.fetchRecentByUserId(0).then(summary => { + const [actualQuery, options] = db.any.mock.calls[0] + expectQueryToMatch(query, actualQuery) + expect(options).toEqual({ userId: 0 }) + checkAgainstMockRawSummary(summary) + }) + }) + + test('fetchAllInCollection generates the correct query and returns a DraftSummary object', () => { + db.any = jest.fn() + db.any.mockResolvedValueOnce(mockRawDraftSummary) + + const whereSQL = 'repository_map_drafts_to_collections.collection_id = $[collectionId]' + const joinSQL = `JOIN repository_map_drafts_to_collections + ON repository_map_drafts_to_collections.draft_id = drafts.id` + const query = queryBuilder(whereSQL, joinSQL) + + return DraftSummary.fetchAllInCollection('mockCollectionId').then(summary => { + expect(db.any).toHaveBeenCalledWith(query, { collectionId: 'mockCollectionId' }) + checkAgainstMockRawSummary(summary) + }) + }) + + test('fetchAllInCollectionForUser generates the correct query and returns a DraftSummary object', () => { + db.any = jest.fn() + db.any.mockResolvedValueOnce(mockRawDraftSummary) + + const whereSQL = `repository_map_drafts_to_collections.collection_id = $[collectionId] + AND repository_map_user_to_draft.user_id = $[userId]` + const joinSQL = `JOIN repository_map_drafts_to_collections + ON repository_map_drafts_to_collections.draft_id = drafts.id + JOIN repository_map_user_to_draft + ON repository_map_user_to_draft.draft_id = drafts.id` + const query = queryBuilder(whereSQL, joinSQL) + + return DraftSummary.fetchAllInCollectionForUser('mockCollectionId', 0).then(summary => { + expect(db.any).toHaveBeenCalledWith(query, { collectionId: 'mockCollectionId', userId: 0 }) + checkAgainstMockRawSummary(summary) }) }) + test('fetchByDraftTitleAndUser generates the correct query and returns a DraftSummary object', () => { + db.any = jest.fn() + db.any.mockResolvedValueOnce(mockRawDraftSummary) + + const whereSQL = 'repository_map_user_to_draft.user_id = $[userId]' + const joinSQL = `JOIN repository_map_user_to_draft + ON repository_map_user_to_draft.draft_id = drafts.id` + const innerQuery = queryBuilder(whereSQL, joinSQL) + const query = ` + SELECT inner_query.* + FROM ( + ${innerQuery} + ) AS inner_query + WHERE inner_query.title ILIKE $[searchString] + ` + + return DraftSummary.fetchByDraftTitleAndUser('searchString', 0).then(summary => { + expect(db.any).toHaveBeenCalledWith(query, { searchString: '%searchString%', userId: 0 }) + checkAgainstMockRawSummary(summary) + }) + }) + + test('fetchByDraftTitleAndUser returns error when no matches are found in the database', () => { + logger.error = jest.fn() + + expect.hasAssertions() + + db.any.mockRejectedValueOnce(new Error('not found in db')) + + return DraftSummary.fetchByDraftTitleAndUser('mockDraftTitle', 0).catch(err => { + expect(logger.error).toHaveBeenCalledWith( + 'fetchByDraftTitleAndUser Error', + 'not found in db', + expect.any(String), + { searchString: '%mockDraftTitle%', userId: 0 } + ) + expect(err).toBe('Error loading DraftSummary by query') + }) + }) + + test('fetchAndJoinWhereLimit catches database errors', () => { + expect.hasAssertions() + + db.any.mockRejectedValueOnce(new Error('not found in db')) + + const whereSQL = '' + const joinSQL = '' + const limitSQL = '' + const mockQueryValues = { id: 'mockDraftId' } + + return DraftSummary.fetchAndJoinWhereLimit(whereSQL, joinSQL, limitSQL, mockQueryValues).catch( + err => { + expect(logger.error).toHaveBeenCalledWith( + 'fetchAndJoinWhereLimit Error', + 'not found in db', + whereSQL, + joinSQL, + limitSQL, + mockQueryValues + ) + expect(err).toBe('Error loading DraftSummary by query') + } + ) + }) + test('fetchAndJoinWhere catches database errors', () => { expect.hasAssertions() const mockError = new Error('not found in db') diff --git a/packages/app/obojobo-repository/server/routes/api.js b/packages/app/obojobo-repository/server/routes/api.js index cd806e168f..9fb75cd19a 100644 --- a/packages/app/obojobo-repository/server/routes/api.js +++ b/packages/app/obojobo-repository/server/routes/api.js @@ -1,6 +1,7 @@ const router = require('express').Router() //eslint-disable-line new-cap const insertEvent = require('obojobo-express/server/insert_event') -const RepositoryCollection = require('../models/collection') +const Collection = require('../models/collection') +const CollectionSummary = require('../models/collection_summary') const Draft = require('obojobo-express/server/models/draft') const DraftSummary = require('../models/draft_summary') const DraftPermissions = require('../models/draft_permissions') @@ -10,16 +11,20 @@ const { requireCurrentUser, requireCurrentDocument, checkValidationRules, + requireCanCreateDrafts, + requireCanDeleteDrafts, check, requireCanViewStatsPage } = require('obojobo-express/server/express_validators') const UserModel = require('obojobo-express/server/models/user') const { searchForUserByString } = require('../services/search') -const publicLibCollectionId = '00000000-0000-0000-0000-000000000000' +const { fetchAllCollectionsForDraft } = require('../services/collections') +const { getUserModuleCount } = require('../services/count') +const publicLibCollectionId = require('../../shared/publicLibCollectionId') // List public drafts router.route('/drafts-public').get((req, res) => { - return RepositoryCollection.fetchById(publicLibCollectionId) + return Collection.fetchById(publicLibCollectionId) .then(collection => collection.loadRelatedDrafts()) .then(collection => { res.success(collection.drafts) @@ -27,14 +32,50 @@ router.route('/drafts-public').get((req, res) => { .catch(res.unexpected) }) +// List my collections +// mounted as /api/collections +router + .route('/collections') + .get([requireCurrentUser]) + .get((req, res) => { + return CollectionSummary.fetchByUserId(req.currentUser.id) + .then(collections => res.success(collections)) + .catch(res.unexpected) + }) + +// List my recently modified drafts +// mounted as /api/recent/drafts +router + .route('/recent/drafts') + .get([requireCurrentUser, requireCanPreviewDrafts]) + .get((req, res) => { + let allCount + return getUserModuleCount(req.currentUser.id) + .then(count => { + allCount = count + return DraftSummary.fetchRecentByUserId(req.currentUser.id) + }) + .then(modules => { + return res.success({ allCount, modules }) + }) + .catch(res.unexpected) + }) + // List my drafts // mounted as /api/drafts router .route('/drafts') .get([requireCurrentUser, requireCanPreviewDrafts]) .get((req, res) => { - return DraftSummary.fetchByUserId(req.currentUser.id) - .then(res.success) + let allCount + return getUserModuleCount(req.currentUser.id) + .then(count => { + allCount = count + return DraftSummary.fetchByUserId(req.currentUser.id) + }) + .then(modules => { + return res.success({ allCount, modules }) + }) .catch(res.unexpected) }) @@ -134,7 +175,7 @@ router .post(async (req, res) => { try { const userId = req.currentUser.id - const draftId = req.params.draftId + const draftId = req.currentDocument.draftId const canCopy = await DraftPermissions.userHasPermissionToCopy(userId, draftId) if (!canCopy) { @@ -182,7 +223,7 @@ router .route('/drafts/:draftId/permission') .get([requireCurrentUser, requireCurrentDocument, requireCanPreviewDrafts]) .get((req, res) => { - return DraftPermissions.getDraftOwners(req.params.draftId) + return DraftPermissions.getDraftOwners(req.currentDocument.draftId) .then(users => { const filteredUsers = users.map(u => u.toJSON()) res.success(filteredUsers) @@ -246,4 +287,161 @@ router } }) +// list the collections a draft is in +router + .route('/drafts/:draftId/collections') + .get([requireCurrentUser, requireCurrentDocument, requireCanPreviewDrafts]) + .get((req, res) => { + return fetchAllCollectionsForDraft(req.currentDocument.draftId) + .then(res.success) + .catch(res.unexpected) + }) + +// list the modules a collection has +router + .route('/collections/:collectionId/modules') + .get([requireCurrentUser, requireCanPreviewDrafts]) + .get((req, res) => { + let allCount + return getUserModuleCount(req.currentUser.id) + .then(count => { + allCount = count + return DraftSummary.fetchAllInCollectionForUser(req.params.collectionId, req.currentUser.id) + }) + .then(modules => { + return res.success({ allCount, modules }) + }) + .catch(res.unexpected) + }) + +router + .route('/collections/:collectionId/modules/search') + .get([requireCurrentUser, requireCanPreviewDrafts]) + .get((req, res) => { + // empty search string? return empty array + if (!req.query.q || !req.query.q.trim()) { + res.success([]) + return + } + + let allCount + return getUserModuleCount(req.currentUser.id) + .then(count => { + allCount = count + return DraftSummary.fetchByDraftTitleAndUser(req.query.q, req.currentUser.id) + }) + .then(modules => { + return res.success({ allCount, modules }) + }) + .catch(res.unexpected) + }) + +// Create a Collection +// mounted as /api/collections/new +router + .route('/collections/new') + .post([requireCanCreateDrafts, checkValidationRules]) + .post((req, res) => { + return Collection.createWithUser(req.currentUser.id) + .then(res.success) + .catch(res.unexpected) + }) + +// Rename a Collection +// mounted as /api/collections/rename +router + .route('/collections/rename') + .post([requireCanCreateDrafts, checkValidationRules]) + .post(async (req, res) => { + try { + const hasPerms = await DraftPermissions.userHasPermissionToCollection( + req.currentUser.id, + req.body.id + ) + if (!hasPerms) { + return res.notAuthorized('You must be the creator of this collection to rename it') + } + const collection = await Collection.rename(req.body.id, req.body.title, req.currentUser.id) + res.success(collection) + } catch (error) { + res.unexpected(error) + } + }) + +// Delete a collection +// mounted as api/collections/:id +router + .route('/collections/:id') + .delete([requireCanDeleteDrafts, checkValidationRules]) + .delete(async (req, res) => { + try { + const hasPerms = await DraftPermissions.userHasPermissionToCollection( + req.currentUser.id, + req.params.id + ) + if (!hasPerms) { + return res.notAuthorized('You must be the creator of this collection to delete it') + } + + const collection = await Collection.delete(req.params.id, req.currentUser.id) + res.success(collection) + } catch (error) { + res.unexpected(error) + } + }) + +// Add a module to a collection +// mounted as api/collections/:id/modules/add +router + .route('/collections/:id/modules/add') + .post([requireCanCreateDrafts, checkValidationRules]) + .post(async (req, res) => { + try { + const hasPerms = await DraftPermissions.userHasPermissionToCollection( + req.currentUser.id, + req.params.id + ) + if (!hasPerms) { + return res.notAuthorized('You must be the creator of this collection to add modules to it') + } + + const collection = await Collection.addModule( + req.params.id, + req.body.draftId, + req.currentUser.id + ) + res.success(collection) + } catch (error) { + res.unexpected(error) + } + }) + +// Remove a module from a collection +// mounted as api/collections/:id/modules/remove +router + .route('/collections/:id/modules/remove') + .delete([requireCanDeleteDrafts, checkValidationRules]) + .delete(async (req, res) => { + try { + const hasPerms = await DraftPermissions.userHasPermissionToCollection( + req.currentUser.id, + req.params.id + ) + if (!hasPerms) { + return res.notAuthorized( + 'You must be the creator of this collection to remove modules from it' + ) + } + + const collection = await Collection.removeModule( + req.params.id, + req.body.draftId, + req.currentUser.id + ) + res.success(collection) + } catch (error) { + res.unexpected(error) + } + }) + module.exports = router diff --git a/packages/app/obojobo-repository/server/routes/api.test.js b/packages/app/obojobo-repository/server/routes/api.test.js index 7dfb9381ce..0c5b3366ab 100644 --- a/packages/app/obojobo-repository/server/routes/api.test.js +++ b/packages/app/obojobo-repository/server/routes/api.test.js @@ -1,19 +1,26 @@ +jest.mock('../services/collections') +jest.mock('../models/collection_summary') jest.mock('../models/collection') jest.mock('../models/draft_summary') jest.mock('obojobo-express/server/models/draft') -jest.mock('../models/draft_permissions') jest.mock('../models/drafts_metadata') jest.mock('../services/search') +jest.mock('../models/draft_permissions') +jest.mock('../services/collections') +jest.mock('../services/count') jest.mock('obojobo-express/server/models/user') jest.mock('obojobo-express/server/insert_event') jest.unmock('fs') // need fs working for view rendering jest.unmock('express') // we'll use supertest + express for this +let CollectionSummary let Collection let DraftSummary let Draft let DraftsMetadata let SearchServices +let CollectionsServices +let CountServices let UserModel let insertEvent let DraftPermissions @@ -79,11 +86,14 @@ describe('repository api route', () => { hasPermission: () => true } mockCurrentDocument = {} + CollectionSummary = require('../models/collection_summary') Collection = require('../models/collection') DraftSummary = require('../models/draft_summary') Draft = require('obojobo-express/server/models/draft') DraftsMetadata = require('../models/drafts_metadata') SearchServices = require('../services/search') + CollectionsServices = require('../services/collections') + CountServices = require('../services/count') DraftPermissions = require('../models/draft_permissions') UserModel = require('obojobo-express/server/models/user') insertEvent = require('obojobo-express/server/insert_event') @@ -122,6 +132,56 @@ describe('repository api route', () => { }) }) + test('get /collections returns the expected response', () => { + const mockResult = [ + { id: 'mockCollectionId1', title: 'whatever1' }, + { id: 'mockCollectionId2', title: 'whatever2' }, + { id: 'mockCollectionId3', title: 'whatever3' } + ] + + CollectionSummary.fetchByUserId = jest.fn() + CollectionSummary.fetchByUserId.mockResolvedValueOnce(mockResult) + + expect.hasAssertions() + + return request(app) + .get('/collections') + .then(response => { + expect(CollectionSummary.fetchByUserId).toHaveBeenCalledWith(mockCurrentUser.id) + expect(response.statusCode).toBe(200) + expect(response.body).toStrictEqual(mockResult) + }) + }) + + test('get /recent/drafts returns the expected response', () => { + expect.hasAssertions() + + const mockResult = [ + { draftId: 'mockDraftId1' }, + { draftId: 'mockDraftId2' }, + { draftId: 'mockDraftId3' } + ] + + CountServices.getUserModuleCount.mockResolvedValueOnce(mockResult.length) + + DraftSummary.fetchRecentByUserId = jest.fn() + DraftSummary.fetchRecentByUserId.mockResolvedValueOnce(mockResult) + + return request(app) + .get('/recent/drafts') + .then(response => { + expect(CountServices.getUserModuleCount).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(DraftSummary.fetchRecentByUserId).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(response.statusCode).toBe(200) + expect(response.body).toEqual({ + allCount: mockResult.length, + modules: mockResult + }) + }) + }) + test('get /drafts returns the expected response', () => { const mockResult = [ { draftId: 'mockDraftId1', title: 'whatever1' }, @@ -129,6 +189,8 @@ describe('repository api route', () => { { draftId: 'mockDraftId3', title: 'whatever3' } ] + CountServices.getUserModuleCount.mockResolvedValueOnce(mockResult.length) + DraftSummary.fetchByUserId = jest.fn() DraftSummary.fetchByUserId.mockResolvedValueOnce(mockResult) @@ -137,10 +199,13 @@ describe('repository api route', () => { return request(app) .get('/drafts') .then(response => { + expect(CountServices.getUserModuleCount).toHaveBeenCalledWith(mockCurrentUser.id) expect(DraftSummary.fetchByUserId).toHaveBeenCalledWith(mockCurrentUser.id) - expect(response.statusCode).toBe(200) - expect(response.body).toEqual(mockResult) + expect(response.body).toEqual({ + allCount: 3, + modules: mockResult + }) }) }) @@ -715,4 +780,435 @@ describe('repository api route', () => { expect(response.error).toHaveProperty('text', 'Server Error: database error') }) }) + + test('get /drafts/:draftId/collections returns the expected response', () => { + expect.hasAssertions() + + const mockResult = [ + { id: 'mockCollectionId1' }, + { id: 'mockCollectionId2' }, + { id: 'mockCollectionId3' } + ] + + CollectionsServices.fetchAllCollectionsForDraft.mockResolvedValueOnce(mockResult) + + return request(app) + .get('/drafts/mockDraftId/collections') + .then(response => { + expect(CollectionsServices.fetchAllCollectionsForDraft).toHaveBeenCalledWith( + mockCurrentDocument.draftId + ) + expect(response.statusCode).toBe(200) + expect(response.body).toStrictEqual(mockResult) + }) + }) + + test('get /collections/:collectionId/modules returns the expected response', () => { + const mockResult = [ + { draftId: 'mockDraftId1' }, + { draftId: 'mockDraftId2' }, + { draftId: 'mockDraftId3' } + ] + + CountServices.getUserModuleCount.mockResolvedValueOnce(mockResult.length) + + DraftSummary.fetchAllInCollectionForUser = jest.fn() + DraftSummary.fetchAllInCollectionForUser.mockResolvedValueOnce(mockResult) + + expect.hasAssertions() + + return request(app) + .get('/collections/mockCollectionId/modules') + .then(response => { + expect(CountServices.getUserModuleCount).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(DraftSummary.fetchAllInCollectionForUser).toHaveBeenCalledWith( + 'mockCollectionId', + mockCurrentUser.id + ) + expect(response.statusCode).toBe(200) + expect(response.body).toEqual({ + allCount: mockResult.length, + modules: mockResult + }) + }) + }) + + test('get /collections/:collectionId/modules/search returns the expected response with a search string', () => { + expect.hasAssertions() + + const mockResult = [ + { draftId: 'mockDraftId1' }, + { draftId: 'mockDraftId2' }, + { draftId: 'mockDraftId3' } + ] + + CountServices.getUserModuleCount.mockResolvedValueOnce(mockResult.length) + + DraftSummary.fetchByDraftTitleAndUser = jest.fn() + DraftSummary.fetchByDraftTitleAndUser.mockResolvedValueOnce(mockResult) + + return request(app) + .get('/collections/mockCollectionId/modules/search?q=searchString') + .then(response => { + expect(CountServices.getUserModuleCount).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(DraftSummary.fetchByDraftTitleAndUser).toHaveBeenCalledWith( + 'searchString', + mockCurrentUser.id + ) + expect(response.statusCode).toBe(200) + expect(response.body).toEqual({ + allCount: mockResult.length, + modules: mockResult + }) + }) + }) + + test('get /collections/:collectionId/modules/search returns the expected response without a search string', () => { + expect.hasAssertions() + + DraftSummary.fetchByDraftTitleAndUser = jest.fn() + + return request(app) + .get('/collections/mockCollectionId/modules/search?q=') + .then(response => { + expect(DraftSummary.fetchByDraftTitleAndUser).not.toHaveBeenCalled() + expect(response.statusCode).toBe(200) + expect(response.body).toStrictEqual([]) + }) + }) + + test('get /collections/:collectionId/modules/search returns the expected response with a search string if the query errors', () => { + expect.hasAssertions() + + CountServices.getUserModuleCount.mockResolvedValueOnce(0) + + DraftSummary.fetchByDraftTitleAndUser = jest.fn() + DraftSummary.fetchByDraftTitleAndUser.mockRejectedValueOnce('database error') + + return request(app) + .get('/collections/mockCollectionId/modules/search?q=searchString') + .then(response => { + expect(CountServices.getUserModuleCount).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(DraftSummary.fetchByDraftTitleAndUser).toHaveBeenCalledTimes(1) + expect(DraftSummary.fetchByDraftTitleAndUser).toHaveBeenCalledWith( + 'searchString', + mockCurrentUser.id + ) + expect(response.statusCode).toBe(500) + expect(response.error).toHaveProperty('text', 'Server Error: database error') + }) + }) + + test('post /collections/new returns the expected response', () => { + expect.hasAssertions() + + const mockResponse = { + id: 'mockCollectionId', + title: 'mockCollectionTitle' + } + + Collection.createWithUser = jest.fn() + Collection.createWithUser.mockResolvedValueOnce(mockResponse) + + return request(app) + .post('/collections/new') + .then(response => { + expect(Collection.createWithUser).toHaveBeenCalledTimes(1) + expect(Collection.createWithUser).toHaveBeenCalledWith(mockCurrentUser.id) + expect(response.statusCode).toBe(200) + expect(response.body).toStrictEqual(mockResponse) + }) + }) + + test('post /collections/rename returns the expected response when the user owns the collection', () => { + expect.hasAssertions() + + const mockNewTitle = 'mockNewTitle' + + const mockCollection = { + id: 'mockCollectionId', + title: mockNewTitle + } + + Collection.rename = jest.fn() + Collection.rename.mockResolvedValueOnce(mockCollection) + + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(true) + + return request(app) + .post('/collections/rename') + .send(mockCollection) + .then(response => { + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledTimes(1) + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledWith( + mockCurrentUser.id, + mockCollection.id + ) + expect(Collection.rename).toHaveBeenCalledTimes(1) + expect(Collection.rename).toHaveBeenCalledWith( + mockCollection.id, + mockCollection.title, + mockCurrentUser.id + ) + expect(response.statusCode).toBe(200) + expect(response.body).toHaveProperty('value') + expect(response.body.value).toStrictEqual(mockCollection) + }) + }) + + test('post /collections/rename handles unexpected errors', () => { + expect.hasAssertions() + + const mockNewTitle = 'mockNewTitle' + + const mockCollection = { + id: 'mockCollectionId', + title: mockNewTitle + } + + DraftPermissions.userHasPermissionToCollection.mockRejectedValueOnce('database error') + + return request(app) + .post('/collections/rename') + .send(mockCollection) + .then(response => { + expect(response.statusCode).toBe(500) + expect(response.body).toHaveProperty('status', 'error') + expect(response.body).toHaveProperty('value') + expect(response.body.value.message).toBe('database error') + }) + }) + + test('post /collections/rename returns the expected response when the user does not own the collection', () => { + expect.hasAssertions() + + const mockNewTitle = 'mockNewTitle' + + const mockCollection = { + id: 'mockCollectionId', + title: mockNewTitle + } + + Collection.rename = jest.fn() + + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(false) + + return request(app) + .post('/collections/rename') + .send(mockCollection) + .then(response => { + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledTimes(1) + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledWith( + mockCurrentUser.id, + mockCollection.id + ) + expect(Collection.rename).toHaveBeenCalledTimes(0) + expect(response.statusCode).toBe(401) + expect(response.body).toHaveProperty('value') + expect(response.body.value).toHaveProperty( + 'message', + 'You must be the creator of this collection to rename it' + ) + }) + }) + + test('delete /collections/:id returns the expected response when user owns the collection', () => { + expect.hasAssertions() + + Collection.delete = jest.fn() + Collection.delete.mockResolvedValueOnce(null) + + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(true) + + return request(app) + .delete('/collections/mockCollectionId') + .then(response => { + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledTimes(1) + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledWith( + mockCurrentUser.id, + 'mockCollectionId' + ) + expect(Collection.delete).toHaveBeenCalledTimes(1) + expect(Collection.delete).toHaveBeenCalledWith('mockCollectionId', mockCurrentUser.id) + expect(response.statusCode).toBe(200) + }) + }) + + test('delete /collections/:id handles errors', () => { + expect.hasAssertions() + + DraftPermissions.userHasPermissionToCollection.mockRejectedValueOnce('some-error') + + return request(app) + .delete('/collections/mockCollectionId') + .then(response => { + expect(response.statusCode).toBe(500) + }) + }) + + test('delete /collections/:id returns the expected response when the user does not own the collection', () => { + expect.hasAssertions() + + Collection.delete = jest.fn() + + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(false) + + return request(app) + .delete('/collections/mockCollectionId') + .then(response => { + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledTimes(1) + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledWith( + mockCurrentUser.id, + 'mockCollectionId' + ) + expect(Collection.delete).toHaveBeenCalledTimes(0) + expect(response.statusCode).toBe(401) + expect(response.error).toHaveProperty('text', 'Not Authorized') + }) + }) + + test('post /collections/:id/modules/add returns the expected response when the user owns the collection', () => { + expect.hasAssertions() + + Collection.addModule = jest.fn() + Collection.addModule.mockResolvedValueOnce(null) + + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(true) + + return request(app) + .post('/collections/mockCollectionId/modules/add') + .send({ draftId: 'mockDraftId' }) + .then(response => { + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledTimes(1) + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledWith( + mockCurrentUser.id, + 'mockCollectionId' + ) + expect(Collection.addModule).toHaveBeenCalledTimes(1) + expect(Collection.addModule).toHaveBeenCalledWith( + 'mockCollectionId', + 'mockDraftId', + mockCurrentUser.id + ) + expect(response.statusCode).toBe(200) + }) + }) + + test('post /collections/:id/modules/add returns the expected response when the user does not own the collection', () => { + expect.hasAssertions() + + Collection.addModule = jest.fn() + + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(false) + + return request(app) + .post('/collections/mockCollectionId/modules/add') + .send({ draftId: 'mockDraftId' }) + .then(response => { + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledTimes(1) + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledWith( + mockCurrentUser.id, + 'mockCollectionId' + ) + expect(Collection.addModule).toHaveBeenCalledTimes(0) + expect(response.statusCode).toBe(401) + expect(response.body).toHaveProperty('value') + expect(response.body.value).toHaveProperty( + 'message', + 'You must be the creator of this collection to add modules to it' + ) + }) + }) + + test('post /collections/:id/modules/add handles errors', () => { + expect.hasAssertions() + + Collection.addModule = jest.fn() + + DraftPermissions.userHasPermissionToCollection.mockRejectedValueOnce('some-error') + + return request(app) + .post('/collections/mockCollectionId/modules/add') + .send({ draftId: 'mockDraftId' }) + .then(response => { + expect(response.statusCode).toBe(500) + expect(response.body).toHaveProperty('status', 'error') + expect(response.body).toHaveProperty('value') + expect(response.body.value.message).toBe('some-error') + }) + }) + + test('delete /collections/:id/modules/remove returns the expected response when the user owns the collection', () => { + expect.hasAssertions() + + Collection.removeModule = jest.fn() + Collection.removeModule.mockResolvedValueOnce(null) + + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(true) + + return request(app) + .delete('/collections/mockCollectionId/modules/remove') + .send({ draftId: 'mockDraftId' }) + .then(response => { + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledTimes(1) + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledWith( + mockCurrentUser.id, + 'mockCollectionId' + ) + expect(Collection.removeModule).toHaveBeenCalledTimes(1) + expect(Collection.removeModule).toHaveBeenCalledWith( + 'mockCollectionId', + 'mockDraftId', + mockCurrentUser.id + ) + expect(response.statusCode).toBe(200) + }) + }) + + test('delete /collections/:id/modules/remove returns the expected response when the user does not own the collection', () => { + expect.hasAssertions() + + Collection.removeModule = jest.fn() + + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(false) + + return request(app) + .delete('/collections/mockCollectionId/modules/remove') + .send({ draftId: 'mockDraftId' }) + .then(response => { + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledTimes(1) + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledWith( + mockCurrentUser.id, + 'mockCollectionId' + ) + expect(Collection.removeModule).toHaveBeenCalledTimes(0) + expect(response.statusCode).toBe(401) + expect(response.body).toHaveProperty('value') + expect(response.body.value).toHaveProperty( + 'message', + 'You must be the creator of this collection to remove modules from it' + ) + }) + }) + + test('delete /collections/:id/modules/remove handles errors', () => { + expect.hasAssertions() + + Collection.removeModule = jest.fn() + + DraftPermissions.userHasPermissionToCollection.mockRejectedValueOnce('some-error') + + return request(app) + .delete('/collections/mockCollectionId/modules/remove') + .send({ draftId: 'mockDraftId' }) + .then(response => { + expect(response.statusCode).toBe(500) + expect(response.body).toHaveProperty('status', 'error') + expect(response.body).toHaveProperty('value') + expect(response.body.value.message).toBe('some-error') + }) + }) }) diff --git a/packages/app/obojobo-repository/server/routes/dashboard.js b/packages/app/obojobo-repository/server/routes/dashboard.js index 2f5e759744..a40a7a928f 100644 --- a/packages/app/obojobo-repository/server/routes/dashboard.js +++ b/packages/app/obojobo-repository/server/routes/dashboard.js @@ -1,39 +1,149 @@ const express = require('express') const router = express.Router() +const CollectionSummary = require('../models/collection_summary') const DraftSummary = require('../models/draft_summary') const { webpackAssetPath } = require('obojobo-express/server/asset_resolver') const { requireCurrentUser, requireCanPreviewDrafts } = require('obojobo-express/server/express_validators') +const { + MODE_RECENT, + MODE_ALL, + MODE_COLLECTION, + MODE_DELETED +} = require('../../shared/repository-constants') +const DraftPermissions = require('obojobo-repository/server/models/draft_permissions') +const { getUserModuleCount } = require('../services/count') +const short = require('short-uuid') -// Dashboard page -// mounted as /dashboard -// NOTE: is an isomorphic react page -router - .route('/dashboard') - .get([requireCurrentUser, requireCanPreviewDrafts]) - .get((req, res) => { - let sortOrder = 'newest' - const cookies = req.headers.cookie.split(';') - const cookieSort = cookies.find(cookie => cookie.includes('sortOrder')) +const defaultOptions = { + collection: { + id: null, + title: null + }, + mode: MODE_RECENT +} - if (cookieSort) { - sortOrder = cookieSort.split('=')[1] - } +const renderDashboard = (req, res, options) => { + let moduleSortOrder = 'newest' + let collectionSortOrder = 'alphabetical' + const cookies = req.headers.cookie.split(';') + const cookieModuleSort = cookies.find(cookie => cookie.includes('moduleSortOrder')) + const cookieCollectionSort = cookies.find(cookie => cookie.includes('collectionSortOrder')) + + if (cookieModuleSort) { + moduleSortOrder = cookieModuleSort.split('=')[1] + } + if (cookieCollectionSort) { + collectionSortOrder = cookieCollectionSort.split('=')[1] + } + + let myCollections = [] + let moduleCount = 0 + let pageTitle = 'Dashboard' - return DraftSummary.fetchByUserId(req.currentUser.id).then(myModules => { + return getUserModuleCount(req.currentUser.id) + .then(count => { + moduleCount = count + return CollectionSummary.fetchByUserId(req.currentUser.id) + }) + .then(collections => { + myCollections = collections + + switch (options.mode) { + case MODE_COLLECTION: + pageTitle = 'View Collection' + return DraftSummary.fetchAllInCollection(options.collection.id, req.currentUser.id) + case MODE_ALL: + return DraftSummary.fetchByUserId(req.currentUser.id) + case MODE_DELETED: + return DraftSummary.fetchDeletedByUserId(req.currentUser.id) + case MODE_RECENT: + default: + moduleSortOrder = 'last updated' + return DraftSummary.fetchRecentByUserId(req.currentUser.id) + } + }) + .then(myModules => { const props = { - title: 'Dashboard', + title: pageTitle, + myCollections, myModules, - sortOrder, + moduleCount, + moduleSortOrder, + collectionSortOrder, currentUser: req.currentUser, // must use webpackAssetPath for all webpack assets to work in dev and production! appCSSUrl: webpackAssetPath('dashboard.css'), - appJsUrl: webpackAssetPath('dashboard.js') + appJsUrl: webpackAssetPath('dashboard.js'), + collection: options.collection, + mode: options.mode } res.render('pages/page-dashboard-server.jsx', props) }) +} + +// Dashboard page +// mounted as /dashboard +// NOTE: is an isomorphic react page +router + .route('/dashboard') + .get([requireCurrentUser, requireCanPreviewDrafts]) + .get((req, res) => { + renderDashboard(req, res, { ...defaultOptions }) + }) + +// Dashboard page - all modules +// mounted as /dashboard/all +router + .route('/dashboard/all') + .get([requireCurrentUser, requireCanPreviewDrafts]) + .get((req, res) => { + renderDashboard(req, res, { ...defaultOptions, mode: MODE_ALL }) + }) + +// Dashboard page - deleted modules +// mounted as /dashboard/deleted +router + .route('/dashboard/deleted') + .get([requireCurrentUser, requireCanPreviewDrafts]) + .get((req, res) => { + renderDashboard(req, res, { ...defaultOptions, mode: MODE_DELETED }) + }) + +// Collection page - modules in collection +// mounted as /collections/:nameOrId +// virtually identical to a dashboard page, hence inclusion here +router + .route('/collections/:nameOrId') + .get([requireCurrentUser, requireCanPreviewDrafts]) + .get(async (req, res) => { + try { + const urlParts = req.params.nameOrId.split('-') + const translator = short() + const collectionId = translator.toUUID(urlParts[urlParts.length - 1]) + + const hasPerms = await DraftPermissions.userHasPermissionToCollection( + req.currentUser.id, + collectionId + ) + + if (!hasPerms) { + return res.notAuthorized('You must be the author of this collection to view this page') + } + + const collection = await CollectionSummary.fetchById(collectionId) + const options = { + ...defaultOptions, + collection, + mode: MODE_COLLECTION + } + + renderDashboard(req, res, options) + } catch (error) { + res.missing() + } }) module.exports = router diff --git a/packages/app/obojobo-repository/server/routes/dashboard.test.js b/packages/app/obojobo-repository/server/routes/dashboard.test.js index a998c86050..09758c7344 100644 --- a/packages/app/obojobo-repository/server/routes/dashboard.test.js +++ b/packages/app/obojobo-repository/server/routes/dashboard.test.js @@ -1,4 +1,8 @@ +jest.mock('../models/collection_summary') jest.mock('../models/draft_summary') +jest.mock('../models/draft_permissions') +jest.mock('../services/count') +jest.mock('short-uuid') jest.unmock('fs') // need fs working for view rendering jest.unmock('express') // we'll use supertest + express for this jest.mock( @@ -31,9 +35,30 @@ let mockDashboardComponent let mockDashboardComponentConstructor jest.mock('obojobo-repository/shared/components/pages/page-dashboard-server') -const componentPropsDesiredProperties = ['title', 'currentUser', 'sortOrder', 'myModules'] +const { + MODE_RECENT, + MODE_ALL, + MODE_COLLECTION, + MODE_DELETED +} = require('../../shared/repository-constants') +const componentPropsDesiredProperties = [ + 'title', + 'collection', + 'currentUser', + 'mode', + 'moduleCount', + 'moduleSortOrder', + 'collectionSortOrder', + 'myCollections', + 'myModules' +] + +let CollectionSummary let DraftSummary +let CountServices +let DraftPermissions +let short // setup express server const path = require('path') @@ -61,6 +86,23 @@ app.use('/', require('obojobo-express/server/express_response_decorator')) app.use('/', require('obojobo-repository/server/routes/dashboard')) describe('repository dashboard route', () => { + const mockSingleCollection = { id: 'mockCollectionId', title: 'mockCollectionTitle' } + + const mockCollectionSummary = [ + { + id: 'mockCollectionId', + title: 'mockCollectionTitle' + }, + { + id: 'mockCollectionId2', + title: 'mockCollectionTitle2' + }, + { + id: 'mockCollectionId3', + title: 'mockCollectionTitle3' + } + ] + const mockModuleSummary = [ { draftId: 'mockDraftId', @@ -82,7 +124,11 @@ describe('repository dashboard route', () => { id: 99, hasPermission: perm => perm === 'canPreviewDrafts' } + CollectionSummary = require('../models/collection_summary') + DraftSummary = require('../models/draft_summary') + CountServices = require('../services/count') DraftSummary = require('../models/draft_summary') + DraftPermissions = require('../models/draft_permissions') //there's extra express garbage attached to the props we care about //this roundabout solution exists to only pull out the ones we want @@ -96,57 +142,393 @@ describe('repository dashboard route', () => { mockDashboardComponentConstructor(desiredProps) return '' }) + + short = require('short-uuid') }) - const generateCookie = (order = 'alphabetical') => { + const generateCookie = (type = 'module', path = 'dashboard', order = 'alphabetical') => { const expires = new Date() expires.setFullYear(expires.getFullYear() + 1) - const commonCookieString = `expires=${expires.toUTCString()}; path=dashboard` - return `sortOrder=${order}; ${commonCookieString}` + const commonCookieString = `expires=${expires.toUTCString()}; path=${path}` + return `${type}SortOrder=${order}; ${commonCookieString}` } - test('get /dashboard sends the correct props to the Dashboard component - specific cookie', () => { + test('get /dashboard sends the correct props to the Dashboard component', () => { expect.hasAssertions() + CountServices.getUserModuleCount.mockResolvedValueOnce(5) + + CollectionSummary.fetchByUserId = jest.fn() + CollectionSummary.fetchByUserId.mockResolvedValueOnce(mockCollectionSummary) + + DraftSummary.fetchAllInCollection = jest.fn() DraftSummary.fetchByUserId = jest.fn() - DraftSummary.fetchByUserId.mockResolvedValueOnce(mockModuleSummary) + DraftSummary.fetchDeletedByUserId = jest.fn() + DraftSummary.fetchRecentByUserId = jest.fn() + + DraftSummary.fetchRecentByUserId.mockResolvedValueOnce(mockModuleSummary) return request(app) .get('/dashboard') - .set('cookie', [generateCookie('newest')]) + .set('cookie', [generateCookie()]) + .then(response => { + expect(CountServices.getUserModuleCount).toHaveBeenCalledTimes(1) + expect(CountServices.getUserModuleCount).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(CollectionSummary.fetchByUserId).toHaveBeenCalledTimes(1) + expect(CollectionSummary.fetchByUserId).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(DraftSummary.fetchRecentByUserId).toHaveBeenCalledTimes(1) + expect(DraftSummary.fetchRecentByUserId).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(DraftSummary.fetchAllInCollection).not.toHaveBeenCalled() + expect(DraftSummary.fetchByUserId).not.toHaveBeenCalled() + expect(DraftSummary.fetchDeletedByUserId).not.toHaveBeenCalled() + + expect(mockDashboardComponent).toHaveBeenCalledTimes(1) + expect(mockDashboardComponentConstructor).toHaveBeenCalledWith({ + title: 'Dashboard', + collection: { + id: null, + title: null + }, + currentUser: mockCurrentUser, + mode: MODE_RECENT, + moduleCount: 5, + moduleSortOrder: 'last updated', + collectionSortOrder: 'alphabetical', + myCollections: mockCollectionSummary, + myModules: mockModuleSummary + }) + expect(response.statusCode).toBe(200) + }) + }) + + test('get /dashboard/all sends the correct props to the Dashboard component with cookies set', () => { + expect.hasAssertions() + + CountServices.getUserModuleCount.mockResolvedValueOnce(5) + + CollectionSummary.fetchByUserId = jest.fn() + CollectionSummary.fetchByUserId.mockResolvedValueOnce(mockCollectionSummary) + + DraftSummary.fetchAllInCollection = jest.fn() + DraftSummary.fetchByUserId = jest.fn() + DraftSummary.fetchDeletedByUserId = jest.fn() + DraftSummary.fetchRecentByUserId = jest.fn() + + DraftSummary.fetchByUserId.mockResolvedValueOnce(mockModuleSummary) + + return request(app) + .get('/dashboard/all') + .set('cookie', [generateCookie('module', 'dashboard/all', 'last updated')]) .then(response => { + expect(CountServices.getUserModuleCount).toHaveBeenCalledTimes(1) + expect(CountServices.getUserModuleCount).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(CollectionSummary.fetchByUserId).toHaveBeenCalledTimes(1) + expect(CollectionSummary.fetchByUserId).toHaveBeenCalledWith(mockCurrentUser.id) + expect(DraftSummary.fetchByUserId).toHaveBeenCalledTimes(1) expect(DraftSummary.fetchByUserId).toHaveBeenCalledWith(mockCurrentUser.id) + expect(DraftSummary.fetchDeletedByUserId).not.toHaveBeenCalled() + expect(DraftSummary.fetchAllInCollection).not.toHaveBeenCalled() + expect(DraftSummary.fetchRecentByUserId).not.toHaveBeenCalled() + expect(mockDashboardComponent).toHaveBeenCalledTimes(1) expect(mockDashboardComponentConstructor).toHaveBeenCalledWith({ title: 'Dashboard', + collection: { + id: null, + title: null + }, currentUser: mockCurrentUser, - sortOrder: 'newest', + mode: MODE_ALL, + moduleCount: 5, + moduleSortOrder: 'last updated', + collectionSortOrder: 'alphabetical', + myCollections: mockCollectionSummary, myModules: mockModuleSummary }) expect(response.statusCode).toBe(200) }) }) - test('get /dashboard sends the correct props to the Dashboard component - no cookie', () => { + test('get /dashboard/all sends the correct props to the Dashboard component with no cookies', () => { expect.hasAssertions() + CountServices.getUserModuleCount.mockResolvedValueOnce(5) + + CollectionSummary.fetchByUserId = jest.fn() + CollectionSummary.fetchByUserId.mockResolvedValueOnce(mockCollectionSummary) + + DraftSummary.fetchAllInCollection = jest.fn() DraftSummary.fetchByUserId = jest.fn() + DraftSummary.fetchDeletedByUserId = jest.fn() + DraftSummary.fetchRecentByUserId = jest.fn() + DraftSummary.fetchByUserId.mockResolvedValueOnce(mockModuleSummary) return request(app) - .get('/dashboard') - .set('cookie', null) + .get('/dashboard/all') + .set('cookie', ['']) .then(response => { + expect(CountServices.getUserModuleCount).toHaveBeenCalledTimes(1) + expect(CountServices.getUserModuleCount).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(CollectionSummary.fetchByUserId).toHaveBeenCalledTimes(1) + expect(CollectionSummary.fetchByUserId).toHaveBeenCalledWith(mockCurrentUser.id) + expect(DraftSummary.fetchByUserId).toHaveBeenCalledTimes(1) expect(DraftSummary.fetchByUserId).toHaveBeenCalledWith(mockCurrentUser.id) + expect(DraftSummary.fetchDeletedByUserId).not.toHaveBeenCalled() + expect(DraftSummary.fetchAllInCollection).not.toHaveBeenCalled() + expect(DraftSummary.fetchRecentByUserId).not.toHaveBeenCalled() + + expect(mockDashboardComponent).toHaveBeenCalledTimes(1) + expect(mockDashboardComponentConstructor).toHaveBeenCalledWith({ + title: 'Dashboard', + collection: { + id: null, + title: null + }, + currentUser: mockCurrentUser, + mode: MODE_ALL, + moduleCount: 5, + moduleSortOrder: 'newest', + collectionSortOrder: 'alphabetical', + myCollections: mockCollectionSummary, + myModules: mockModuleSummary + }) + expect(response.statusCode).toBe(200) + }) + }) + + test('get /collections/:nameOrId sends the correct props to the Dashboard component with cookies set and the collection exists and the user owns the collection', () => { + expect.hasAssertions() + + const mockShortToUUID = jest.fn() + mockShortToUUID.mockReturnValue('mockCollectionLongId') + short.mockReturnValue({ + toUUID: mockShortToUUID + }) + + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(true) + + CountServices.getUserModuleCount.mockResolvedValueOnce(5) + + CollectionSummary.fetchById = jest.fn() + CollectionSummary.fetchById.mockResolvedValueOnce(mockSingleCollection) + + CollectionSummary.fetchByUserId = jest.fn() + CollectionSummary.fetchByUserId.mockResolvedValueOnce(mockCollectionSummary) + + DraftSummary.fetchAllInCollection = jest.fn() + DraftSummary.fetchByUserId = jest.fn() + DraftSummary.fetchDeletedByUserId = jest.fn() + DraftSummary.fetchRecentByUserId = jest.fn() + + DraftSummary.fetchAllInCollection.mockResolvedValueOnce(mockModuleSummary) + + const path = 'collections/mock-collection-safe-name-mockCollectionShortId' + + return request(app) + .get(`/${path}`) + .set('cookie', [generateCookie('collection', path, 'newest')]) + .then(response => { + expect(mockShortToUUID).toHaveBeenCalledTimes(1) + expect(mockShortToUUID).toHaveBeenCalledWith('mockCollectionShortId') + + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledTimes(1) + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledWith( + mockCurrentUser.id, + 'mockCollectionLongId' + ) + + expect(CollectionSummary.fetchById).toHaveBeenCalledTimes(1) + expect(CollectionSummary.fetchById).toHaveBeenCalledWith('mockCollectionLongId') + + expect(CountServices.getUserModuleCount).toHaveBeenCalledTimes(1) + expect(CountServices.getUserModuleCount).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(CollectionSummary.fetchByUserId).toHaveBeenCalledTimes(1) + expect(CollectionSummary.fetchByUserId).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(DraftSummary.fetchAllInCollection).toHaveBeenCalledTimes(1) + expect(DraftSummary.fetchAllInCollection).toHaveBeenCalledWith( + mockSingleCollection.id, + mockCurrentUser.id + ) + + expect(DraftSummary.fetchByUserId).not.toHaveBeenCalled() + expect(DraftSummary.fetchDeletedByUserId).not.toHaveBeenCalled() + expect(DraftSummary.fetchRecentByUserId).not.toHaveBeenCalled() + + expect(mockDashboardComponent).toHaveBeenCalledTimes(1) + expect(mockDashboardComponentConstructor).toHaveBeenCalledWith({ + title: 'View Collection', + collection: mockSingleCollection, + currentUser: mockCurrentUser, + mode: MODE_COLLECTION, + moduleCount: 5, + moduleSortOrder: 'newest', + collectionSortOrder: 'newest', + myCollections: mockCollectionSummary, + myModules: mockModuleSummary + }) + expect(response.statusCode).toBe(200) + }) + }) + + test('get /collections/:nameOrId sends the correct response when the collection exists but the user does not own the collection', () => { + expect.hasAssertions() + + const mockShortToUUID = jest.fn() + mockShortToUUID.mockReturnValue('mockCollectionLongId') + short.mockReturnValue({ + toUUID: mockShortToUUID + }) + + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(false) + + CollectionSummary.fetchById = jest.fn() + + CollectionSummary.fetchByUserId = jest.fn() + + DraftSummary.fetchAllInCollection = jest.fn() + DraftSummary.fetchByUserId = jest.fn() + DraftSummary.fetchDeletedByUserId = jest.fn() + DraftSummary.fetchRecentByUserId = jest.fn() + + const path = 'collections/mock-collection-safe-name-mockCollectionShortId' + + return request(app) + .get(`/${path}`) + .set('cookie', ['']) + .then(response => { + expect(mockShortToUUID).toHaveBeenCalledTimes(1) + expect(mockShortToUUID).toHaveBeenCalledWith('mockCollectionShortId') + + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledTimes(1) + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledWith( + mockCurrentUser.id, + 'mockCollectionLongId' + ) + + expect(CollectionSummary.fetchById).not.toHaveBeenCalled() + + expect(CountServices.getUserModuleCount).not.toHaveBeenCalled() + + expect(CollectionSummary.fetchByUserId).not.toHaveBeenCalled() + + expect(DraftSummary.fetchAllInCollection).not.toHaveBeenCalled() + expect(DraftSummary.fetchByUserId).not.toHaveBeenCalled() + expect(DraftSummary.fetchDeletedByUserId).not.toHaveBeenCalled() + expect(DraftSummary.fetchRecentByUserId).not.toHaveBeenCalled() + + expect(mockDashboardComponent).not.toHaveBeenCalled() + expect(response.statusCode).toBe(401) + }) + }) + + test('get /collections/:nameOrId sends the correct response when the user owns the collection but a database error occurs', () => { + expect.hasAssertions() + + const mockShortToUUID = jest.fn() + mockShortToUUID.mockReturnValue('mockCollectionLongId') + short.mockReturnValue({ + toUUID: mockShortToUUID + }) + + DraftPermissions.userHasPermissionToCollection.mockResolvedValueOnce(true) + + CollectionSummary.fetchById = jest.fn() + CollectionSummary.fetchById.mockRejectedValueOnce(new Error('database error')) + + CollectionSummary.fetchByUserId = jest.fn() + + DraftSummary.fetchAllInCollection = jest.fn() + DraftSummary.fetchByUserId = jest.fn() + DraftSummary.fetchDeletedByUserId = jest.fn() + DraftSummary.fetchRecentByUserId = jest.fn() + + const path = '/collections/mock-collection-safe-name-mockCollectionShortId' + + return request(app) + .get(path) + .set('cookie', ['']) + .then(response => { + expect(mockShortToUUID).toHaveBeenCalledTimes(1) + expect(mockShortToUUID).toHaveBeenCalledWith('mockCollectionShortId') + + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledTimes(1) + expect(DraftPermissions.userHasPermissionToCollection).toHaveBeenCalledWith( + mockCurrentUser.id, + 'mockCollectionLongId' + ) + + expect(CollectionSummary.fetchById).toHaveBeenCalledTimes(1) + expect(CollectionSummary.fetchById).toHaveBeenCalledWith('mockCollectionLongId') + + expect(CountServices.getUserModuleCount).not.toHaveBeenCalled() + + expect(CollectionSummary.fetchByUserId).not.toHaveBeenCalled() + + expect(DraftSummary.fetchAllInCollection).not.toHaveBeenCalled() + expect(DraftSummary.fetchByUserId).not.toHaveBeenCalled() + expect(DraftSummary.fetchDeletedByUserId).not.toHaveBeenCalled() + expect(DraftSummary.fetchRecentByUserId).not.toHaveBeenCalled() + + expect(mockDashboardComponent).not.toHaveBeenCalled() + expect(response.statusCode).toBe(404) + }) + }) + + test('get /dashboard/deleted sends the correct props to the Dashboard component with cookies set', () => { + expect.hasAssertions() + + CountServices.getUserModuleCount.mockResolvedValueOnce(5) + + CollectionSummary.fetchByUserId = jest.fn() + DraftSummary.fetchAllInCollection = jest.fn() + DraftSummary.fetchByUserId = jest.fn() + DraftSummary.fetchDeletedByUserId = jest.fn() + DraftSummary.fetchRecentByUserId = jest.fn() + + DraftSummary.fetchDeletedByUserId.mockResolvedValueOnce(mockModuleSummary) + + return request(app) + .get('/dashboard/deleted') + .set('cookie', [generateCookie('module', 'dashboard/deleted', 'last updated')]) + .then(response => { + expect(CountServices.getUserModuleCount).toHaveBeenCalledTimes(1) + expect(CountServices.getUserModuleCount).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(CollectionSummary.fetchByUserId).toHaveBeenCalledTimes(1) + expect(CollectionSummary.fetchByUserId).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(DraftSummary.fetchDeletedByUserId).toHaveBeenCalledTimes(1) + expect(DraftSummary.fetchDeletedByUserId).toHaveBeenCalledWith(mockCurrentUser.id) + + expect(DraftSummary.fetchByUserId).not.toHaveBeenCalled() + expect(DraftSummary.fetchAllInCollection).not.toHaveBeenCalled() + expect(DraftSummary.fetchRecentByUserId).not.toHaveBeenCalled() + expect(mockDashboardComponent).toHaveBeenCalledTimes(1) expect(mockDashboardComponentConstructor).toHaveBeenCalledWith({ title: 'Dashboard', + collection: { + id: null, + title: null + }, currentUser: mockCurrentUser, - sortOrder: 'newest', + mode: MODE_DELETED, + moduleCount: 5, + moduleSortOrder: 'last updated', + collectionSortOrder: 'alphabetical', + myCollections: [], myModules: mockModuleSummary }) expect(response.statusCode).toBe(200) diff --git a/packages/app/obojobo-repository/server/routes/library.js b/packages/app/obojobo-repository/server/routes/library.js index 375aef64b8..50d6e404f2 100644 --- a/packages/app/obojobo-repository/server/routes/library.js +++ b/packages/app/obojobo-repository/server/routes/library.js @@ -1,5 +1,5 @@ const router = require('express').Router() //eslint-disable-line new-cap -const RepositoryCollection = require('../models/collection') +const Collection = require('../models/collection') const DraftSummary = require('../models/draft_summary') const UserModel = require('obojobo-express/server/models/user') const { webpackAssetPath } = require('obojobo-express/server/asset_resolver') @@ -111,7 +111,7 @@ router .route('/library') .get(getCurrentUser) .get((req, res) => { - return RepositoryCollection.fetchById(publicLibCollectionId) + return Collection.fetchById(publicLibCollectionId) .then(collection => { return collection.loadRelatedDrafts() }) diff --git a/packages/app/obojobo-repository/server/services/search.js b/packages/app/obojobo-repository/server/services/search.js index 76671ef0d4..19dd0c5080 100644 --- a/packages/app/obojobo-repository/server/services/search.js +++ b/packages/app/obojobo-repository/server/services/search.js @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ const db = require('obojobo-express/server/db') const UserModel = require('obojobo-express/server/models/user') diff --git a/packages/app/obojobo-repository/shared/actions/dashboard-actions.js b/packages/app/obojobo-repository/shared/actions/dashboard-actions.js index 935302f211..0a70b11b40 100644 --- a/packages/app/obojobo-repository/shared/actions/dashboard-actions.js +++ b/packages/app/obojobo-repository/shared/actions/dashboard-actions.js @@ -1,3 +1,4 @@ +const { MODE_RECENT, MODE_ALL, MODE_COLLECTION } = require('../repository-constants') const debouncePromise = require('debounce-promise') const dayjs = require('dayjs') const advancedFormat = require('dayjs/plugin/advancedFormat') @@ -19,6 +20,10 @@ const defaultOptions = () => ({ } }) +const defaultModuleModeOptions = { + mode: null +} + const throwIfNotOk = res => { if (!res.ok) throw Error(`Error requesting ${res.url}, status code: ${res.status}`) return res @@ -41,6 +46,19 @@ const apiGetPermissionsForModule = draftId => { return fetch(`/api/drafts/${draftId}/permission`, defaultOptions()).then(res => res.json()) } +const apiGetCollectionsForModule = draftId => { + return fetch(`/api/drafts/${draftId}/collections`, defaultOptions()).then(res => res.json()) +} + +const apiAddModuleToCollection = (draftId, collectionId) => { + const options = { ...defaultOptions(), method: 'POST', body: `{"draftId":"${draftId}"}` } + return fetch(`/api/collections/${collectionId}/modules/add`, options).then(res => res.json()) +} +const apiRemoveModuleFromCollection = (draftId, collectionId) => { + const options = { ...defaultOptions(), method: 'DELETE', body: `{"draftId":"${draftId}"}` } + return fetch(`/api/collections/${collectionId}/modules/remove`, options).then(res => res.json()) +} + const apiSaveDraft = async (draftId, draftJSON) => { if (typeof draftJSON !== 'string') draftJSON = JSON.stringify(draftJSON) const options = { ...defaultOptions(), method: 'POST', body: draftJSON } @@ -109,11 +127,16 @@ const apiDeletePermissionsToModule = (draftId, userId) => { return fetch(`/api/drafts/${draftId}/permission/${userId}`, options).then(res => res.json()) } -const apiDeleteModule = draftId => { - const options = { ...defaultOptions(), method: 'DELETE' } +const apiDeleteModule = (draftId, collectionId) => { + const body = JSON.stringify({ collectionId }) + const options = { ...defaultOptions(), method: 'DELETE', body } return fetch(`/api/drafts/${draftId}`, options).then(res => res.json()) } +const apiGetMyCollections = () => { + return fetch('/api/collections', defaultOptions()).then(res => res.json()) +} + const apiRestoreModule = draftId => { const options = { ...defaultOptions(), method: 'PUT' } return fetch(`/api/drafts/restore/${draftId}`, options).then(res => res.json()) @@ -123,13 +146,47 @@ const apiGetMyModules = () => { return fetch('/api/drafts', defaultOptions()).then(res => res.json()) } +const apiGetMyRecentModules = () => { + return fetch('/api/recent/drafts', defaultOptions()).then(res => res.json()) +} + +const apiCreateNewCollection = () => { + const url = '/api/collections/new' + const options = { ...defaultOptions(), method: 'POST' } + return fetch(url, options).then(res => res.json()) +} + +const apiGetModulesForCollection = collectionId => { + return fetch(`/api/collections/${collectionId}/modules`, defaultOptions()).then(res => res.json()) +} + +const apiSearchForModuleNotInCollection = (searchString, collectionId) => { + return fetch( + `/api/collections/${collectionId}/modules/search?q=${searchString}`, + defaultOptions() + ).then(res => res.json()) +} + +const apiRenameCollection = (id, title) => { + const url = '/api/collections/rename' + const body = JSON.stringify({ id, title }) + const options = { ...defaultOptions(), method: 'POST', body } + return fetch(url, options).then(res => res.json()) +} + +const apiDeleteCollection = collection => { + const options = { ...defaultOptions(), method: 'DELETE' } + return fetch(`/api/collections/${collection.id}`, options).then(res => res.json()) +} + const apiGetMyDeletedModules = () => { return fetch('/api/drafts-deleted', defaultOptions()).then(res => res.json()) } -const apiCreateNewModule = (useTutorial, body = {}) => { +const apiCreateNewModule = (useTutorial, moduleContent, collectionId = null) => { const url = useTutorial ? '/api/drafts/tutorial' : '/api/drafts/new' - const options = { ...defaultOptions(), method: 'POST', body: JSON.stringify(body) } + const body = JSON.stringify({ collectionId, moduleContent }) + const options = { ...defaultOptions(), method: 'POST', body } return fetch(url, options).then(res => res.json()) } @@ -206,17 +263,36 @@ const addUserToModule = (draftId, userId) => ({ }) const DELETE_MODULE_PERMISSIONS = 'DELETE_MODULE_PERMISSIONS' -const deleteModulePermissions = (draftId, userId) => ({ - type: DELETE_MODULE_PERMISSIONS, - promise: apiDeletePermissionsToModule(draftId, userId) - .then(() => { - return Promise.all([apiGetMyModules(), apiGetPermissionsForModule(draftId)]) - }) - .then(results => ({ - value: results[1].value, - modules: results[0].value - })) -}) +const deleteModulePermissions = (draftId, userId, options = { ...defaultModuleModeOptions }) => { + let apiModuleGetCall + + switch (options.mode) { + case MODE_COLLECTION: + apiModuleGetCall = () => { + return apiGetModulesForCollection(options.collectionId) + } + break + case MODE_RECENT: + apiModuleGetCall = apiGetMyRecentModules + break + case MODE_ALL: + default: + apiModuleGetCall = apiGetMyModules + break + } + + return { + type: DELETE_MODULE_PERMISSIONS, + promise: apiDeletePermissionsToModule(draftId, userId) + .then(() => { + return Promise.all([apiModuleGetCall(), apiGetPermissionsForModule(draftId)]) + }) + .then(results => ({ + value: results[1].value, + modules: results[0].value + })) + } +} const LOAD_USERS_FOR_MODULE = 'LOAD_USERS_FOR_MODULE' const loadUsersForModule = draftId => ({ @@ -225,9 +301,36 @@ const loadUsersForModule = draftId => ({ }) const DELETE_MODULE = 'DELETE_MODULE' -const deleteModule = draftId => ({ - type: DELETE_MODULE, - promise: apiDeleteModule(draftId).then(apiGetMyModules) +const deleteModule = (draftId, options = { ...defaultModuleModeOptions }) => { + let apiModuleGetCall + let collectionId = null + + switch (options.mode) { + case MODE_COLLECTION: + collectionId = options.collectionId + apiModuleGetCall = () => { + return apiGetModulesForCollection(options.collectionId) + } + break + case MODE_RECENT: + apiModuleGetCall = apiGetMyRecentModules + break + case MODE_ALL: + default: + apiModuleGetCall = apiGetMyModules + break + } + + return { + type: DELETE_MODULE, + promise: apiDeleteModule(draftId, collectionId).then(apiModuleGetCall) + } +} + +const CREATE_NEW_COLLECTION = 'CREATE_NEW_COLLECTION' +const createNewCollection = () => ({ + type: CREATE_NEW_COLLECTION, + promise: apiCreateNewCollection().then(apiGetMyCollections) }) const BULK_DELETE_MODULES = 'BULK_DELETE_MODULES' @@ -236,17 +339,64 @@ const bulkDeleteModules = draftIds => ({ promise: Promise.all(draftIds.map(id => apiDeleteModule(id))).then(apiGetMyModules) }) +const BULK_ADD_MODULES_TO_COLLECTIONS = 'BULK_ADD_MODULES_TO_COLLECTIONS' +const bulkAddModulesToCollection = (draftIds, collectionIds) => { + const allPromises = [] + draftIds.forEach(draftId => { + collectionIds.forEach(collectionId => { + allPromises.push(apiAddModuleToCollection(draftId, collectionId)) + }) + }) + + return { + type: BULK_ADD_MODULES_TO_COLLECTIONS, + promise: Promise.all(allPromises) + } +} + +const BULK_REMOVE_MODULES_FROM_COLLECTION = 'BULK_REMOVE_MODULES_FROM_COLLECTION' +const bulkRemoveModulesFromCollection = (draftIds, collectionId) => ({ + type: BULK_REMOVE_MODULES_FROM_COLLECTION, + meta: { + changedCollectionId: collectionId, + currentCollectionId: collectionId + }, + promise: Promise.all( + draftIds.map(draftId => apiRemoveModuleFromCollection(draftId, collectionId)) + ).then(() => apiGetModulesForCollection(collectionId)) +}) + const BULK_RESTORE_MODULES = 'BULK_RESTORE_MODULES' const bulkRestoreModules = draftIds => ({ type: BULK_RESTORE_MODULES, - promise: Promise.all(draftIds.map(id => apiRestoreModule(id))).then(apiGetMyModules) + promise: Promise.all(draftIds.map(id => apiRestoreModule(id))).then(apiGetMyDeletedModules) }) const CREATE_NEW_MODULE = 'CREATE_NEW_MODULE' -const createNewModule = (useTutorial = false) => ({ - type: CREATE_NEW_MODULE, - promise: apiCreateNewModule(useTutorial).then(apiGetMyModules) -}) +const createNewModule = (useTutorial = false, options = { ...defaultModuleModeOptions }) => { + let apiModuleGetCall + let collectionId = null + + switch (options.mode) { + case MODE_COLLECTION: + collectionId = options.collectionId + apiModuleGetCall = () => { + return apiGetModulesForCollection(options.collectionId) + } + break + case MODE_RECENT: + apiModuleGetCall = apiGetMyRecentModules + break + case MODE_ALL: + default: + apiModuleGetCall = apiGetMyModules + break + } + return { + type: CREATE_NEW_MODULE, + promise: apiCreateNewModule(useTutorial, {}, collectionId).then(apiModuleGetCall) + } +} const FILTER_MODULES = 'FILTER_MODULES' const filterModules = searchString => ({ @@ -254,6 +404,12 @@ const filterModules = searchString => ({ searchString }) +const FILTER_COLLECTIONS = 'FILTER_COLLECTIONS' +const filterCollections = searchString => ({ + type: FILTER_COLLECTIONS, + searchString +}) + const SELECT_MODULES = 'SELECT_MODULES' const selectModules = draftIds => ({ type: SELECT_MODULES, @@ -272,6 +428,125 @@ const showModuleMore = module => ({ module }) +const SHOW_MODULE_MANAGE_COLLECTIONS = 'SHOW_MODULE_MANAGE_COLLECTIONS' +const showModuleManageCollections = module => ({ + type: SHOW_MODULE_MANAGE_COLLECTIONS, + module +}) + +const LOAD_MODULE_COLLECTIONS = 'LOAD_MODULE_COLLECTIONS' +const loadModuleCollections = draftId => ({ + type: LOAD_MODULE_COLLECTIONS, + promise: apiGetCollectionsForModule(draftId) +}) + +const MODULE_ADD_TO_COLLECTION = 'MODULE_ADD_TO_COLLECTION' +const moduleAddToCollection = (draftId, collectionId) => ({ + type: MODULE_ADD_TO_COLLECTION, + promise: apiAddModuleToCollection(draftId, collectionId).then(() => { + return apiGetCollectionsForModule(draftId) + }) +}) + +const MODULE_REMOVE_FROM_COLLECTION = 'MODULE_REMOVE_FROM_COLLECTION' +const moduleRemoveFromCollection = (draftId, collectionId) => ({ + type: MODULE_REMOVE_FROM_COLLECTION, + promise: apiRemoveModuleFromCollection(draftId, collectionId).then(() => { + return apiGetCollectionsForModule(draftId) + }) +}) + +const SHOW_COLLECTION_BULK_ADD_MODULES_DIALOG = 'SHOW_COLLECTION_BULK_ADD_MODULES_DIALOG' +const showCollectionBulkAddModulesDialog = selectedModules => ({ + type: SHOW_COLLECTION_BULK_ADD_MODULES_DIALOG, + selectedModules +}) + +const SHOW_COLLECTION_MANAGE_MODULES = 'SHOW_COLLECTION_MANAGE_MODULES' +const showCollectionManageModules = collection => ({ + type: SHOW_COLLECTION_MANAGE_MODULES, + collection +}) + +const LOAD_COLLECTION_MODULES = 'LOAD_COLLECTION_MODULES' +const loadCollectionModules = (collectionId, options = { ...defaultModuleModeOptions }) => ({ + type: LOAD_COLLECTION_MODULES, + meta: { + changedCollectionId: collectionId, + currentCollectionId: options.collectionId || null + }, + promise: apiGetModulesForCollection(collectionId) +}) + +const COLLECTION_ADD_MODULE = 'COLLECTION_ADD_MODULE' +const collectionAddModule = (draftId, collectionId, options = { ...defaultModuleModeOptions }) => { + return { + type: COLLECTION_ADD_MODULE, + meta: { + changedCollectionId: collectionId, + currentCollectionId: options.collectionId + }, + promise: apiAddModuleToCollection(draftId, collectionId).then(() => { + return apiGetModulesForCollection(collectionId) + }) + } +} + +const COLLECTION_REMOVE_MODULE = 'COLLECTION_REMOVE_MODULE' +const collectionRemoveModule = ( + draftId, + collectionId, + options = { ...defaultModuleModeOptions } +) => { + return { + type: COLLECTION_REMOVE_MODULE, + meta: { + changedCollectionId: collectionId, + currentCollectionId: options.collectionId + }, + promise: apiRemoveModuleFromCollection(draftId, collectionId).then(() => { + return apiGetModulesForCollection(collectionId) + }) + } +} + +const LOAD_MODULE_SEARCH = 'LOAD_MODULE_SEARCH' +const searchForModuleNotInCollection = (searchString, collectionId) => ({ + type: LOAD_MODULE_SEARCH, + meta: { + searchString + }, + promise: apiSearchForModuleNotInCollection(searchString, collectionId) +}) + +const CLEAR_MODULE_SEARCH_RESULTS = 'CLEAR_MODULE_SEARCH_RESULTS' +const clearModuleSearchResults = () => ({ type: CLEAR_MODULE_SEARCH_RESULTS }) + +const SHOW_COLLECTION_RENAME = 'SHOW_COLLECTION_RENAME' +const showCollectionRename = collection => ({ + type: SHOW_COLLECTION_RENAME, + collection +}) + +const RENAME_COLLECTION = 'RENAME_COLLECTION' +const renameCollection = (collectionId, newTitle, options = { ...defaultModuleModeOptions }) => { + return { + type: RENAME_COLLECTION, + meta: { + changedCollectionTitle: newTitle, + changedCollectionId: collectionId, + currentCollectionId: options.collectionId + }, + promise: apiRenameCollection(collectionId, newTitle).then(apiGetMyCollections) + } +} + +const DELETE_COLLECTION = 'DELETE_COLLECTION' +const deleteCollection = collection => ({ + type: DELETE_COLLECTION, + promise: apiDeleteCollection(collection).then(apiGetMyCollections) +}) + const IMPORT_MODULE_FILE = 'IMPORT_MODULE_FILE' const importModuleFile = searchString => ({ type: IMPORT_MODULE_FILE, @@ -325,7 +600,6 @@ const moduleUploadFileLoaded = async (boundResolve, boundReject, fileType, e) => content: e.target.result, format: fileType === JSON_MIME_TYPE ? JSON_MIME_TYPE : XML_MIME_TYPE_APPLICATION } - await apiCreateNewModule(false, body) window.location.reload() boundResolve() @@ -345,10 +619,28 @@ module.exports = { DELETE_MODULE_PERMISSIONS, DELETE_MODULE, BULK_DELETE_MODULES, + BULK_ADD_MODULES_TO_COLLECTIONS, + BULK_REMOVE_MODULES_FROM_COLLECTION, FILTER_MODULES, + FILTER_COLLECTIONS, SELECT_MODULES, DESELECT_MODULES, SHOW_MODULE_MORE, + CREATE_NEW_COLLECTION, + SHOW_MODULE_MANAGE_COLLECTIONS, + LOAD_MODULE_COLLECTIONS, + MODULE_ADD_TO_COLLECTION, + MODULE_REMOVE_FROM_COLLECTION, + SHOW_COLLECTION_BULK_ADD_MODULES_DIALOG, + SHOW_COLLECTION_MANAGE_MODULES, + LOAD_COLLECTION_MODULES, + COLLECTION_ADD_MODULE, + COLLECTION_REMOVE_MODULE, + LOAD_MODULE_SEARCH, + CLEAR_MODULE_SEARCH_RESULTS, + SHOW_COLLECTION_RENAME, + RENAME_COLLECTION, + DELETE_COLLECTION, SHOW_VERSION_HISTORY, RESTORE_VERSION, IMPORT_MODULE_FILE, @@ -358,19 +650,37 @@ module.exports = { GET_MODULES, BULK_RESTORE_MODULES, filterModules, + filterCollections, selectModules, deselectModules, deleteModule, bulkDeleteModules, + bulkAddModulesToCollection, + bulkRemoveModulesFromCollection, closeModal, deleteModulePermissions, searchForUser, addUserToModule, + createNewCollection, createNewModule, showModulePermissions, loadUsersForModule, clearPeopleSearchResults, showModuleMore, + showCollectionManageModules, + loadCollectionModules, + collectionAddModule, + collectionRemoveModule, + searchForModuleNotInCollection, + clearModuleSearchResults, + showCollectionRename, + showCollectionBulkAddModulesDialog, + showModuleManageCollections, + loadModuleCollections, + moduleAddToCollection, + moduleRemoveFromCollection, + renameCollection, + deleteCollection, showVersionHistory, restoreVersion, importModuleFile, 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 83c3f232ab..ddf5f996b1 100644 --- a/packages/app/obojobo-repository/shared/actions/dashboard-actions.test.js +++ b/packages/app/obojobo-repository/shared/actions/dashboard-actions.test.js @@ -15,6 +15,8 @@ describe('Dashboard Actions', () => { const JSON_MIME_TYPE = 'application/json' const XML_MIME_TYPE = 'application/xml' + const { MODE_RECENT, MODE_ALL, MODE_COLLECTION } = require('../repository-constants') + const originalFetch = global.fetch const originalCreateElement = document.createElement const originalFileReader = global.FileReader @@ -84,6 +86,24 @@ describe('Dashboard Actions', () => { window.location.reload = originalWindowLocationReload }) + const expectGetMyCollectionsCalled = () => { + expect(global.fetch).toHaveBeenCalledWith('/api/collections', defaultFetchOptions) + } + + const expectGetCollectionsForModuleCalled = () => { + expect(global.fetch).toHaveBeenCalledWith( + `/api/drafts/mockDraftId/collections`, + defaultFetchOptions + ) + } + + const expectGetModulesForCollectionCalled = () => { + expect(global.fetch).toHaveBeenCalledWith( + '/api/collections/mockCollectionId/modules', + defaultFetchOptions + ) + } + test('showModulePermissions returns the expected output', () => { const mockModule = { draftId: 'mockDraftId' } const actionReply = DashboardActions.showModulePermissions(mockModule) @@ -237,6 +257,25 @@ describe('Dashboard Actions', () => { }) }) } + //options will contain mode: MODE_COLLECTION and collectionId + test('deleteModulePermissions returns expected output and calls other functions, mode MODE_COLLECTION', () => { + const options = { + mode: MODE_COLLECTION, + collectionId: 'mockCollectionId' + } + return assertDeleteModulePermissionsRunsWithOptions( + '/api/drafts/mockDraftId/permission', + options + ) + }) + //options will contain mode: MODE_RECENT + test('deleteModulePermissions returns expected output and calls other functions, mode MODE_RECENT', () => { + return assertDeleteModulePermissionsRunsWithOptions('/api/recent/drafts', { mode: MODE_RECENT }) + }) + //options will contain mode: MODE_ALL + test('deleteModulePermissions returns expected output and calls other functions, mode MODE_ALL', () => { + return assertDeleteModulePermissionsRunsWithOptions('/api/drafts', { mode: MODE_ALL }) + }) // no options, default should be equivalent to MODE_ALL test('deleteModulePermissions returns expected output and calls other functions, default', () => { return assertDeleteModulePermissionsRunsWithOptions('/api/drafts') @@ -290,11 +329,63 @@ describe('Dashboard Actions', () => { }) }) } + //options will contain mode: MODE_COLLECTION and collectionId + test('deleteModule returns expected output and calls other functions, mode MODE_COLLECTION', () => { + const options = { + mode: MODE_COLLECTION, + collectionId: 'mockCollectionId' + } + return assertDeleteModuleRunsWithOptions( + '/api/collections/mockCollectionId/modules', + '{"collectionId":"mockCollectionId"}', + options + ) + }) + //options will contain mode: MODE_RECENT + test('deleteModule returns expected output and calls other functions, mode MODE_RECENT', () => { + return assertDeleteModuleRunsWithOptions('/api/recent/drafts', '{"collectionId":null}', { + mode: MODE_RECENT + }) + }) + //options will contain mode: MODE_ALL + test('deleteModule returns expected output and calls other functions, mode MODE_ALL', () => { + return assertDeleteModuleRunsWithOptions('/api/drafts', '{"collectionId":null}', { + mode: MODE_ALL + }) + }) + // no options, default should be equivalent to MODE_ALL test('deleteModule returns expected output and calls other functions, default', () => { - return assertDeleteModuleRunsWithOptions('/api/drafts') + return assertDeleteModuleRunsWithOptions('/api/drafts', '{"collectionId":null}') + }) + + test('createNewCollection returns the expected output', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + const actionReply = DashboardActions.createNewCollection() + + expect(global.fetch).toHaveBeenCalledWith('/api/collections/new', { + ...defaultFetchOptions, + method: 'POST' + }) + + global.fetch.mockReset() + global.fetch.mockResolvedValueOnce({ + json: () => ({ value: 'mockCollectionList' }) + }) + + expect(actionReply).toEqual({ + type: DashboardActions.CREATE_NEW_COLLECTION, + promise: expect.any(Object) + }) + + return actionReply.promise.then(finalResponse => { + expectGetMyCollectionsCalled() + + expect(finalResponse).toEqual({ value: 'mockCollectionList' }) + }) }) const assertBulkDeleteModulesRunsWithOptions = (secondaryLookupUrl, fetchBody, options) => { + if (typeof fetchBody === 'undefined') fetchBody = '{}' global.fetch.mockResolvedValue(standardFetchResponse) const actionReply = DashboardActions.bulkDeleteModules( ['mockDraftId1', 'mockDraftId2'], @@ -335,6 +426,80 @@ describe('Dashboard Actions', () => { return assertBulkDeleteModulesRunsWithOptions('/api/drafts') }) + test('bulkAddModulesToCollection calls other functions', () => { + global.fetch.mockResolvedValue(standardFetchResponse) + + const mockDraftIds = ['draft-id-1', 'draft-id-2', 'draft-id-3'] + const mockCollectionIds = ['collection-id-1', 'collection-id-2'] + + const actionReply = DashboardActions.bulkAddModulesToCollection(mockDraftIds, mockCollectionIds) + + expect(actionReply).toEqual({ + type: DashboardActions.BULK_ADD_MODULES_TO_COLLECTIONS, + promise: expect.any(Object) + }) + + actionReply.promise.then(() => { + const expectedNumberOfCalls = mockDraftIds.length * mockCollectionIds.length + expect(global.fetch).toHaveBeenCalledTimes(expectedNumberOfCalls) + + let callIndex = 0 + mockDraftIds.forEach(draftId => { + mockCollectionIds.forEach(collectionId => { + const expectedCall = global.fetch.mock.calls[callIndex++] + const expectedApiUrl = `/api/collections/${collectionId}/modules/add` + expect(expectedCall[0]).toBe(expectedApiUrl) + expect(expectedCall[1]).toEqual({ + ...defaultFetchOptions, + method: 'POST', + body: `{"draftId":"${draftId}"}` + }) + }) + }) + + expect(callIndex).toEqual(expectedNumberOfCalls) + }) + }) + + test('bulkRemoveModulesFromCollection returns expected output and calls other functions', () => { + global.fetch.mockResolvedValue(standardFetchResponse) + + const mockCollectionId = 'mockCollectionId' + + const mockDraftIds = ['draft-id-1', 'draft-id-2', 'draft-id-3'] + + const actionReply = DashboardActions.bulkRemoveModulesFromCollection( + mockDraftIds, + mockCollectionId + ) + + expect(actionReply).toEqual({ + type: DashboardActions.BULK_REMOVE_MODULES_FROM_COLLECTION, + meta: { + changedCollectionId: mockCollectionId, + currentCollectionId: mockCollectionId + }, + promise: expect.any(Object) + }) + + actionReply.promise.then(() => { + expect(global.fetch).toHaveBeenCalledTimes(mockDraftIds.length + 1) + mockDraftIds.forEach((draftId, index) => { + const expectedCall = global.fetch.mock.calls[index] + expect(expectedCall[0]).toBe(`/api/collections/${mockCollectionId}/modules/remove`) + expect(expectedCall[1]).toEqual({ + ...defaultFetchOptions, + method: 'DELETE', + body: `{"draftId":"${draftId}"}` + }) + }) + + const lastCall = global.fetch.mock.calls[mockDraftIds.length] + expect(lastCall[0]).toBe(`/api/collections/${mockCollectionId}/modules`) + expect(lastCall[1]).toEqual(defaultFetchOptions) + }) + }) + // three (plus one default) ways of calling createNewModule plus tutorial/normal module const assertCreateNewModuleRunsWithOptions = ( createUrl, @@ -370,18 +535,59 @@ describe('Dashboard Actions', () => { }) }) } + //options will contain mode: MODE_COLLECTION and collectionId + test('createNewModule returns expected output and calls other functions, mode MODE_COLLECTION', () => { + const options = { + mode: MODE_COLLECTION, + collectionId: 'mockCollectionId' + } + return assertCreateNewModuleRunsWithOptions( + '/api/drafts/new', + '{"collectionId":"mockCollectionId","moduleContent":{}}', + '/api/collections/mockCollectionId/modules', + false, + options + ) + }) + //options will contain mode: MODE_RECENT + test('createNewModule returns expected output and calls other functions, mode MODE_RECENT', () => { + const options = { mode: MODE_RECENT } + return assertCreateNewModuleRunsWithOptions( + '/api/drafts/new', + '{"collectionId":null,"moduleContent":{}}', + '/api/recent/drafts', + false, + options + ) + }) + //options will contain mode: MODE_ALL + test('createNewModule returns expected output and calls other functions, mode MODE_ALL', () => { + const options = { mode: MODE_ALL } + return assertCreateNewModuleRunsWithOptions( + '/api/drafts/new', + '{"collectionId":null,"moduleContent":{}}', + '/api/drafts', + false, + options + ) + }) // no options, default should be equivalent to MODE_ALL - test('createNewModule returns expected output and calls other functions', () => { + test('createNewModule returns expected output and calls other functions, mode MODE_ALL', () => { return assertCreateNewModuleRunsWithOptions( '/api/drafts/new', - '{}', + '{"collectionId":null,"moduleContent":{}}', '/api/drafts' //no argument indicating tutorial - should default to false ) }) // same as above, but making a tutorial - test('createNewModule returns expected output and calls other functions, tutorial', () => { - return assertCreateNewModuleRunsWithOptions('/api/drafts/tutorial', '{}', '/api/drafts', true) + test('createNewModule returns expected output and calls other functions, mode MODE_ALL, tutorial', () => { + return assertCreateNewModuleRunsWithOptions( + '/api/drafts/tutorial', + '{"collectionId":null,"moduleContent":{}}', + '/api/drafts', + true + ) }) test('filterModules returns the expected output', () => { @@ -394,6 +600,16 @@ describe('Dashboard Actions', () => { }) }) + test('filterCollections returns the expected output', () => { + const actionReply = DashboardActions.filterCollections('mockSearchString') + + expect(global.fetch).not.toHaveBeenCalled() + expect(actionReply).toEqual({ + type: DashboardActions.FILTER_COLLECTIONS, + searchString: 'mockSearchString' + }) + }) + test('selectModules returns the expected output', () => { const actionReply = DashboardActions.selectModules(['mockDraftId1', 'mockDraftId2']) @@ -428,6 +644,434 @@ describe('Dashboard Actions', () => { }) }) + test('showModuleManageCollections returns the expected output', () => { + const mockModule = { + draftId: 'mockDraftId', + title: 'Mock Draft Title' + } + const actionReply = DashboardActions.showModuleManageCollections(mockModule) + + expect(global.fetch).not.toHaveBeenCalled() + expect(actionReply).toEqual({ + type: DashboardActions.SHOW_MODULE_MANAGE_COLLECTIONS, + module: mockModule + }) + }) + + test('loadModuleCollections returns the expected output', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + + const actionReply = DashboardActions.loadModuleCollections('mockDraftId') + + expectGetCollectionsForModuleCalled() + + expect(actionReply).toEqual({ + type: DashboardActions.LOAD_MODULE_COLLECTIONS, + promise: expect.any(Object) + }) + + return actionReply.promise.then(() => { + expect(standardFetchResponse.json).toHaveBeenCalled() + }) + }) + + test('moduleAddToCollection returns the expected output and calls other functions correctly', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + + const actionReply = DashboardActions.moduleAddToCollection('mockDraftId', 'mockCollectionId') + + expect(global.fetch).toHaveBeenCalledWith('/api/collections/mockCollectionId/modules/add', { + ...defaultFetchOptions, + method: 'POST', + body: '{"draftId":"mockDraftId"}' + }) + global.fetch.mockReset() + global.fetch.mockResolvedValueOnce({ + json: () => ({ value: 'mockSecondaryPermissionsVal' }) + }) + + expect(actionReply).toEqual({ + type: DashboardActions.MODULE_ADD_TO_COLLECTION, + promise: expect.any(Object) + }) + + // should get draft permissions after changing them + return actionReply.promise.then(finalResponse => { + expect(standardFetchResponse.json).toHaveBeenCalled() + expectGetCollectionsForModuleCalled() + expect(finalResponse).toEqual({ value: 'mockSecondaryPermissionsVal' }) + }) + }) + + test('moduleRemoveFromCollection returns the expected output and calls other functions correctly', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + + const actionReply = DashboardActions.moduleRemoveFromCollection( + 'mockDraftId', + 'mockCollectionId' + ) + + expect(global.fetch).toHaveBeenCalledWith('/api/collections/mockCollectionId/modules/remove', { + ...defaultFetchOptions, + method: 'DELETE', + body: '{"draftId":"mockDraftId"}' + }) + global.fetch.mockReset() + global.fetch.mockResolvedValueOnce({ + json: () => ({ value: 'mockSecondaryPermissionsVal' }) + }) + + expect(actionReply).toEqual({ + type: DashboardActions.MODULE_REMOVE_FROM_COLLECTION, + promise: expect.any(Object) + }) + + // should get draft permissions after changing them + return actionReply.promise.then(finalResponse => { + expect(standardFetchResponse.json).toHaveBeenCalled() + expectGetCollectionsForModuleCalled() + expect(finalResponse).toEqual({ value: 'mockSecondaryPermissionsVal' }) + }) + }) + + test('showCollectionBulkAddModulesDialog returns the expected output', () => { + const mockSelectedModules = [] + const actionReply = DashboardActions.showCollectionBulkAddModulesDialog(mockSelectedModules) + + expect(global.fetch).not.toHaveBeenCalled() + expect(actionReply).toEqual({ + type: DashboardActions.SHOW_COLLECTION_BULK_ADD_MODULES_DIALOG, + selectedModules: mockSelectedModules + }) + }) + + test('showCollectionManageModules returns the expected output', () => { + const mockCollection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' + } + const actionReply = DashboardActions.showCollectionManageModules(mockCollection) + + expect(global.fetch).not.toHaveBeenCalled() + expect(actionReply).toEqual({ + type: DashboardActions.SHOW_COLLECTION_MANAGE_MODULES, + collection: mockCollection + }) + }) + + test('loadCollectionModules returns the expected output, no options', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + + const actionReply = DashboardActions.loadCollectionModules('mockCollectionId') + + expectGetModulesForCollectionCalled() + + expect(actionReply).toEqual({ + type: DashboardActions.LOAD_COLLECTION_MODULES, + meta: { + changedCollectionId: 'mockCollectionId', + currentCollectionId: null + }, + promise: expect.any(Object) + }) + + return actionReply.promise.then(() => { + expect(standardFetchResponse.json).toHaveBeenCalled() + }) + }) + test('loadCollectionModules returns the expected output, options', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + + const options = { collectionId: 'otherMockCollectionId' } + const actionReply = DashboardActions.loadCollectionModules('mockCollectionId', options) + + expectGetModulesForCollectionCalled() + + expect(actionReply).toEqual({ + type: DashboardActions.LOAD_COLLECTION_MODULES, + meta: { + changedCollectionId: 'mockCollectionId', + currentCollectionId: 'otherMockCollectionId' + }, + promise: expect.any(Object) + }) + }) + + test('collectionAddModule returns the expected output and calls other functions, no options', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + + const actionReply = DashboardActions.collectionAddModule('mockDraftId', 'mockCollectionId') + + expect(global.fetch).toHaveBeenCalledWith('/api/collections/mockCollectionId/modules/add', { + ...defaultFetchOptions, + method: 'POST', + body: '{"draftId":"mockDraftId"}' + }) + global.fetch.mockReset() + global.fetch.mockResolvedValueOnce({ + json: () => ({ value: 'mockSecondaryPermissionsVal' }) + }) + + expect(actionReply).toEqual({ + type: DashboardActions.COLLECTION_ADD_MODULE, + meta: { + changedCollectionId: 'mockCollectionId', + currentCollectionId: undefined // eslint-disable-line no-undefined + }, + promise: expect.any(Object) + }) + + return actionReply.promise.then(finalResponse => { + expectGetModulesForCollectionCalled() + expect(standardFetchResponse.json).toHaveBeenCalled() + expect(finalResponse).toEqual({ value: 'mockSecondaryPermissionsVal' }) + }) + }) + test('collectionAddModule returns the expected output and calls other functions with options', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + + const options = { collectionId: 'otherMockCollectionId' } + const actionReply = DashboardActions.collectionAddModule( + 'mockDraftId', + 'mockCollectionId', + options + ) + + expect(global.fetch).toHaveBeenCalledWith('/api/collections/mockCollectionId/modules/add', { + ...defaultFetchOptions, + method: 'POST', + body: '{"draftId":"mockDraftId"}' + }) + global.fetch.mockReset() + global.fetch.mockResolvedValueOnce({ + json: () => ({ value: 'mockSecondaryPermissionsVal' }) + }) + + expect(actionReply).toEqual({ + type: DashboardActions.COLLECTION_ADD_MODULE, + meta: { + changedCollectionId: 'mockCollectionId', + currentCollectionId: 'otherMockCollectionId' + }, + promise: expect.any(Object) + }) + + return actionReply.promise.then(finalResponse => { + expectGetModulesForCollectionCalled() + expect(standardFetchResponse.json).toHaveBeenCalled() + expect(finalResponse).toEqual({ value: 'mockSecondaryPermissionsVal' }) + }) + }) + + test('collectionRemoveModule returns the expected output and calls other functions, no options', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + + const actionReply = DashboardActions.collectionRemoveModule('mockDraftId', 'mockCollectionId') + + expect(global.fetch).toHaveBeenCalledWith('/api/collections/mockCollectionId/modules/remove', { + ...defaultFetchOptions, + method: 'DELETE', + body: '{"draftId":"mockDraftId"}' + }) + global.fetch.mockReset() + global.fetch.mockResolvedValueOnce({ + json: () => ({ value: 'mockSecondaryPermissionsVal' }) + }) + + expect(actionReply).toEqual({ + type: DashboardActions.COLLECTION_REMOVE_MODULE, + meta: { + changedCollectionId: 'mockCollectionId', + currentCollectionId: undefined // eslint-disable-line no-undefined + }, + promise: expect.any(Object) + }) + + return actionReply.promise.then(finalResponse => { + expectGetModulesForCollectionCalled() + expect(standardFetchResponse.json).toHaveBeenCalled() + expect(finalResponse).toEqual({ value: 'mockSecondaryPermissionsVal' }) + }) + }) + test('collectionRemoveModule returns the expected output and calls other functions with options', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + + const options = { collectionId: 'otherMockCollectionId' } + const actionReply = DashboardActions.collectionRemoveModule( + 'mockDraftId', + 'mockCollectionId', + options + ) + + expect(global.fetch).toHaveBeenCalledWith('/api/collections/mockCollectionId/modules/remove', { + ...defaultFetchOptions, + method: 'DELETE', + body: '{"draftId":"mockDraftId"}' + }) + global.fetch.mockReset() + global.fetch.mockResolvedValueOnce({ + json: () => ({ value: 'mockSecondaryPermissionsVal' }) + }) + + expect(actionReply).toEqual({ + type: DashboardActions.COLLECTION_REMOVE_MODULE, + meta: { + changedCollectionId: 'mockCollectionId', + currentCollectionId: 'otherMockCollectionId' + }, + promise: expect.any(Object) + }) + + return actionReply.promise.then(finalResponse => { + expectGetModulesForCollectionCalled() + expect(standardFetchResponse.json).toHaveBeenCalled() + expect(finalResponse).toEqual({ value: 'mockSecondaryPermissionsVal' }) + }) + }) + + test('searchForModuleNotInCollection returns the expected output and calls other functions', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + + const actionReply = DashboardActions.searchForModuleNotInCollection( + 'searchString', + 'mockCollectionId' + ) + + expect(global.fetch).toHaveBeenCalledWith( + '/api/collections/mockCollectionId/modules/search?q=searchString', + defaultFetchOptions + ) + global.fetch.mockReset() + global.fetch.mockResolvedValueOnce({ + json: () => ({ value: 'mockSecondaryPermissionsVal' }) + }) + + expect(actionReply).toEqual({ + type: DashboardActions.LOAD_MODULE_SEARCH, + meta: { + searchString: 'searchString' + }, + promise: expect.any(Object) + }) + + return actionReply.promise.then(() => { + expect(standardFetchResponse.json).toHaveBeenCalled() + }) + }) + + test('clearModuleSearchResults returns the expected output', () => { + const actionReply = DashboardActions.clearModuleSearchResults() + + expect(global.fetch).not.toHaveBeenCalled() + expect(actionReply).toEqual({ + type: DashboardActions.CLEAR_MODULE_SEARCH_RESULTS + }) + }) + + test('showCollectionRename returns the expected output', () => { + const mockCollection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' + } + const actionReply = DashboardActions.showCollectionRename(mockCollection) + + expect(global.fetch).not.toHaveBeenCalled() + expect(actionReply).toEqual({ + type: DashboardActions.SHOW_COLLECTION_RENAME, + collection: mockCollection + }) + }) + + test('renameCollection returns the expected output and calls other functions correctly, no options', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + + const actionReply = DashboardActions.renameCollection('mockCollectionId', 'New Title') + + expect(global.fetch).toHaveBeenCalledWith('/api/collections/rename', { + ...defaultFetchOptions, + method: 'POST', + body: '{"id":"mockCollectionId","title":"New Title"}' + }) + global.fetch.mockReset() + global.fetch.mockResolvedValueOnce({ + json: () => ({ value: 'mockSecondReturnVal' }) + }) + + expect(actionReply).toEqual({ + type: DashboardActions.RENAME_COLLECTION, + meta: { + changedCollectionTitle: 'New Title', + changedCollectionId: 'mockCollectionId', + currentCollectionId: undefined //eslint-disable-line no-undefined + }, + promise: expect.any(Object) + }) + + return actionReply.promise.then(finalResult => { + expectGetMyCollectionsCalled() + expect(standardFetchResponse.json).toHaveBeenCalled() + expect(finalResult).toEqual({ value: 'mockSecondReturnVal' }) + }) + }) + test('renameCollection returns the expected output and calls other functions correctly with options', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + const options = { collectionId: 'otherMockCollectionId' } + const actionReply = DashboardActions.renameCollection('mockCollectionId', 'New Title', options) + + expect(global.fetch).toHaveBeenCalledWith('/api/collections/rename', { + ...defaultFetchOptions, + method: 'POST', + body: '{"id":"mockCollectionId","title":"New Title"}' + }) + global.fetch.mockReset() + global.fetch.mockResolvedValueOnce({ + json: () => ({ value: 'mockSecondReturnVal' }) + }) + + expect(actionReply).toEqual({ + type: DashboardActions.RENAME_COLLECTION, + meta: { + changedCollectionTitle: 'New Title', + changedCollectionId: 'mockCollectionId', + currentCollectionId: 'otherMockCollectionId' + }, + promise: expect.any(Object) + }) + + return actionReply.promise.then(finalResult => { + expectGetMyCollectionsCalled() + expect(standardFetchResponse.json).toHaveBeenCalled() + expect(finalResult).toEqual({ value: 'mockSecondReturnVal' }) + }) + }) + + test('deleteCollection returns the expected output and calls other functions correctly', () => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + + const mockCollection = { id: 'mockCollectionId', title: 'Mock Collection Title' } + const actionReply = DashboardActions.deleteCollection(mockCollection) + + expect(global.fetch).toHaveBeenCalledWith('/api/collections/mockCollectionId', { + ...defaultFetchOptions, + method: 'DELETE' + }) + global.fetch.mockReset() + global.fetch.mockResolvedValueOnce({ + json: () => ({ value: 'mockSecondReturnVal' }) + }) + + expect(actionReply).toEqual({ + type: DashboardActions.DELETE_COLLECTION, + promise: expect.any(Object) + }) + + return actionReply.promise.then(finalResult => { + expectGetMyCollectionsCalled() + expect(standardFetchResponse.json).toHaveBeenCalled() + expect(finalResult).toEqual({ value: 'mockSecondReturnVal' }) + }) + }) + test('importModuleFile returns the expected output and calls other functions correctly - no file', () => { const actionReply = DashboardActions.importModuleFile() expect(actionReply).toEqual({ @@ -497,15 +1141,18 @@ describe('Dashboard Actions', () => { ...defaultFetchOptions, method: 'POST', body: JSON.stringify({ - content: 'fileContent', - format: 'application/json' + collectionId: null, + moduleContent: { + content: 'fileContent', + format: 'application/json' + } }) }) expect(window.location.reload).toHaveBeenCalledTimes(1) }) }) - test('importModuleFile returns the expected output and calls other functions correctly - valid file, non-json', () => { + test('importModuleFile returns the expected output and calls other functions correctly - valid file, xml', () => { global.fetch.mockResolvedValueOnce({ ...standardFetchResponse, ok: true }) const actionReply = DashboardActions.importModuleFile() @@ -544,8 +1191,11 @@ describe('Dashboard Actions', () => { ...defaultFetchOptions, method: 'POST', body: JSON.stringify({ - content: 'fileContent', - format: 'application/xml' //defaults to this unless file type is 'application/json' + collectionId: null, + moduleContent: { + content: 'fileContent', + format: 'application/xml' //defaults to this unless file type is 'application/json' + } }) }) expect(window.location.reload).toHaveBeenCalledTimes(1) @@ -593,8 +1243,11 @@ describe('Dashboard Actions', () => { ...defaultFetchOptions, method: 'POST', body: JSON.stringify({ - content: 'fileContent', - format: JSON_MIME_TYPE + collectionId: null, + moduleContent: { + content: 'fileContent', + format: JSON_MIME_TYPE + } }) }) expect(window.location.reload).not.toHaveBeenCalled() @@ -1237,7 +1890,7 @@ describe('Dashboard Actions', () => { }) } test('bulkRestoreModules returns expected output and calls other functions', () => { - return assertBulkRestoreModulesRunsWithOptions('/api/drafts') + return assertBulkRestoreModulesRunsWithOptions('/api/drafts-deleted') }) const assertGetMyDeletedModulesRunsWithOptions = (secondaryLookupUrl, fetchBody, options) => { diff --git a/packages/app/obojobo-repository/shared/components/__snapshots__/module-options-dialog.test.js.snap b/packages/app/obojobo-repository/shared/components/__snapshots__/module-options-dialog.test.js.snap index 5d5f54c71c..e8287032ca 100644 --- a/packages/app/obojobo-repository/shared/components/__snapshots__/module-options-dialog.test.js.snap +++ b/packages/app/obojobo-repository/shared/components/__snapshots__/module-options-dialog.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ModuleOptionsDialog ModuleOptionsDialog renders correctly with standard expected props 1`] = ` +exports[`ModuleOptionsDialog renders correctly with standard expected props 1`] = `
@@ -99,6 +99,17 @@ exports[`ModuleOptionsDialog ModuleOptionsDialog renders correctly with standard > View and restore previous versions.
+ +
+ Add to or remove from private collections. +
+ + + + ) +} + +const extendedPropsDefault = props => ({ + renameCollection: props.renameCollection, + collectionAddModule: props.collectionAddModule, + collectionRemoveModule: props.collectionRemoveModule, + moduleAddToCollection: props.moduleAddToCollection, + moduleRemoveFromCollection: props.moduleRemoveFromCollection, + deleteModule: props.deleteModule, + deleteModulePermissions: props.deleteModulePermissions +}) + const getModuleCount = modules => { if (modules.length === 1) { - return '1 Module Selected: ' + return '1 Module Selected:' } else { return `${modules.length} Modules Selected:` } @@ -141,24 +315,18 @@ const getSortMethod = sortOrder => { } function Dashboard(props) { - const [sortOrder, setSortOrder] = useState(props.sortOrder) + const [moduleSortOrder, setModuleSortOrder] = useState(props.moduleSortOrder) + const [collectionSortOrder, setCollectionSortOrder] = useState(props.collectionSortOrder) const [newModuleId, setNewModuleId] = useState(null) + const [lastPreselectedIndex, setLastPreselectedIndex] = useState(null) const [lastSelectedIndex, setLastSelectedIndex] = useState(0) const [isLoading, setIsLoading] = useState(false) - let moduleList = [] - if ( - props.filteredModules && - props.filteredModules.length > 0 && - typeof props.filteredModules !== 'undefined' - ) { - moduleList = props.filteredModules - } else { - moduleList = props.myModules - } + const moduleList = props.filteredModules ? props.filteredModules : props.myModules const onKeyUp = e => { if (e.key === 'Escape' && props.multiSelectMode && props.deselectModules) { + setLastPreselectedIndex(null) props.deselectModules(props.selectedModules) } } @@ -167,21 +335,18 @@ function Dashboard(props) { modalProps.startLoadingAnimation = () => setIsLoading(true) modalProps.stopLoadingAnimation = () => setIsLoading(false) - const handleCreateNewModule = useTutorial => { - setIsLoading(true) - - props.createNewModule(useTutorial).then(data => { - setIsLoading(false) - data.payload.value.sort(getSortMethod('newest')) - setNewModuleId(data.payload.value[0].draftId) - }) - } - const handleSelectModule = (event, draftId, index) => { + let originIndex = lastSelectedIndex if (event.shiftKey && lastSelectedIndex !== index) { + // No module is 'selected' yet - set the shift-click 'origin' from the last one that was clicked + if (!props.selectedModules.length) { + // If shift-clicking a module without having first clicked another module, only select the one that was clicked + originIndex = lastPreselectedIndex !== null ? lastPreselectedIndex : index + } + // Accommodates for group selecting backwards in the list and prevents duplicate selections const [startIdx, endIdx] = - lastSelectedIndex < index ? [lastSelectedIndex, index + 1] : [index, lastSelectedIndex + 1] + originIndex < index ? [originIndex, index + 1] : [index, originIndex + 1] const idList = moduleList.map(m => m.draftId) props.selectModules( idList.slice(startIdx, endIdx).filter(id => !props.selectedModules.includes(id)) @@ -195,16 +360,57 @@ function Dashboard(props) { setLastSelectedIndex(index) } + const renderModules = (modules, sortOrder, newModuleId, newModuleButton) => { + if (modules.length < 1) { + return ( +

+ + Nothing to see here! + + {props.mode !== MODE_DELETED && newModuleButton} +

+ ) + } + + const sortFn = getSortMethod(sortOrder) + return modules.sort(sortFn).map((draft, index) => ( +
setLastPreselectedIndex(index)} + > + handleSelectModule(e, draft.draftId, index)} + hasMenu={true} + isDeleted={props.mode === MODE_DELETED} + {...draft} + /> +
+ )) + } + + const removeModulesFromCollection = draftIds => { + setIsLoading(true) + // eslint-disable-next-line no-alert, no-undef + const response = prompt( + `Are you sure you want to remove these ${draftIds.length} selected modules from this collection? Type 'REMOVE' to confirm.` + ) + if (response !== 'REMOVE') return setIsLoading(false) + props + .bulkRemoveModulesFromCollection(draftIds, props.collection.id) + .then(() => setIsLoading(false)) + } + const deleteModules = draftIds => { setIsLoading(true) // eslint-disable-next-line no-alert, no-undef const response = prompt( `Are you sure you want to DELETE these ${draftIds.length} selected modules? Type 'DELETE' to confirm.` ) - if (response !== 'DELETE') { - setIsLoading(false) - return - } + if (response !== 'DELETE') return setIsLoading(false) props.bulkDeleteModules(draftIds).then(() => setIsLoading(false)) } @@ -212,28 +418,309 @@ function Dashboard(props) { setIsLoading(true) props.bulkRestoreModules(draftIds).then(() => { setIsLoading(false) - alert('The selected modules were successfully restored.') + // eslint-disable-next-line no-alert + window.alert('The selected modules were successfully restored.') }) } - const switchTabs = () => { - if (props.showDeletedModules) { - props.getModules() - } else { - props.getDeletedModules() - } - } - - // Set a cookie when sortOrder changes on the client + // Set a cookie when moduleSortOrder changes on the client // can't undefine document to test this 'else' case without breaking everything - maybe later /* istanbul ignore else */ if (typeof document !== 'undefined') { useEffect(() => { const expires = new Date() expires.setFullYear(expires.getFullYear() + 1) - document.cookie = `sortOrder=${sortOrder}; expires=${expires.toUTCString()}; path=/dashboard` - setLastSelectedIndex(0) - }, [sortOrder]) + let modeUrlString = '/dashboard' + + // Placeholder variable - will be set to an instance of shortUUID if needed + let translator = null + + switch (props.mode) { + case MODE_COLLECTION: + translator = short() + modeUrlString = + '/collections/' + + encodeURI(props.collection.title.replace(/\s+/g, '-').toLowerCase()) + + '-' + + translator.fromUUID(props.collection.id) + break + case MODE_RECENT: + // Default view is 'only show the five most recent changes', do nothing with the path + break + case MODE_DELETED: + modeUrlString = `${modeUrlString}/deleted` + break + case MODE_ALL: + default: + modeUrlString = `${modeUrlString}/all` + break + } + + const commonCookieString = `expires=${expires.toUTCString()}; path=${modeUrlString}` + document.cookie = `moduleSortOrder=${moduleSortOrder}; ${commonCookieString}` + document.cookie = `collectionSortOrder=${collectionSortOrder}; ${commonCookieString}` + }, [moduleSortOrder, collectionSortOrder]) + } + + useEffect(() => { + // Reset last selected index when leaving multi-select mode + if (!props.multiSelectMode) setLastSelectedIndex(0) + }, [props.multiSelectMode]) + + useEffect(() => { + document.addEventListener('keyup', onKeyUp) + return () => { + document.removeEventListener('keyup', onKeyUp) + } + }, [onKeyUp]) + + const newCollectionButtonRender = ( + + ) + + // Elements to render in the 'My Collections' part of the page + // Will only appear when dashboard is in 'recent' mode + let collectionAreaRender = null + + // Text content of the dashboard module section's title + // Either 'My Recent Modules' (recent), 'My Modules' (all), or 'Modules In + Sort + + + ) + + // Components to render for module filter input + // Will not be necessary when dashboard is in 'recent' mode + let moduleFilterRender = ( + + ) + + // Components to render an 'All Modules' button (default mode) + // Will not be necessary when dashboard is in 'all' or 'collection' modes + let allModulesButtonRender = null + + // 'New Collection' button and horizontal divider + // Will only appear when dashboard is in 'recent' mode + let newCollectionOptionsRender = null + + const createNewModuleOptions = { + mode: props.mode + } + + // Components for managing the current collection + // Will only appear when dashboard is in 'collection' mode + let collectionManageAreaRender = null + + // reusable function to build a 'collections' list below the module list in 'all' and 'recent' modes + const renderCollectionArea = () => { + newCollectionOptionsRender = ( + + {newCollectionButtonRender} +
+
+ ) + + let collectionFilterRender = null + if (props.myCollections.length > 0) { + collectionFilterRender = ( + + ) + } + + collectionAreaRender = ( + +
+ My Collections +
+ Sort + +
+
+ {collectionFilterRender} +
+
+
+
+ {renderCollections( + props.filteredCollections ? props.filteredCollections : props.myCollections, + collectionSortOrder, + newCollectionButtonRender + )} +
+
+
+
+
+ ) + } + + switch (props.mode) { + // url is /collections/collection-name-and-short-uuid + case MODE_COLLECTION: + collectionManageAreaRender = renderCollectionManageArea(props) + createNewModuleOptions.collectionId = props.collection.id + modulesTitle = `Modules in '${props.collection.title}'` + break + // url is /dashboard/all + case MODE_ALL: + renderCollectionArea() + modulesTitle = 'My Modules' + break + // url is /dashboard/deleted + case MODE_DELETED: + modulesTitle = 'My Deleted Modules' + break + // url is /dashboard + case MODE_RECENT: + default: + allModulesButtonRender = ( + + All Modules + + ) + + renderCollectionArea() + + moduleFilterRender = null + moduleSortRender = null + modulesTitleExtraClass = 'stretch-width' + } + + const onNewModuleClick = useTutorial => { + setIsLoading(true) + + props.createNewModule(useTutorial, createNewModuleOptions).then(data => { + setIsLoading(false) + data.payload.value.modules.sort(getSortMethod('newest')) + setNewModuleId(data.payload.value.modules[0].draftId) + }) + } + + const newModuleButtonRender = + + const deletedModulesButtonLinkRender = + props.mode === MODE_ALL ? ( + +
+ Deleted Modules +
+ ) : null + + let mainControlBarRender = ( +
+ {props.mode === MODE_DELETED ? ( + +
+ + + +
+ Return to All Modules +
+ ) : ( + + {newCollectionOptionsRender} + {newModuleButtonRender} + + + + )} + {deletedModulesButtonLinkRender} + {collectionManageAreaRender} + {moduleFilterRender} +
+ ) + if (props.multiSelectMode && props.selectedModules.length > 0) { + let bulkCollectionActionButton = null + let bulkActionButton = ( + + ) + switch (props.mode) { + case MODE_COLLECTION: + bulkCollectionActionButton = ( + + ) + break + case MODE_DELETED: + bulkActionButton = ( + + ) + break + case MODE_ALL: + case MODE_RECENT: + default: + bulkCollectionActionButton = ( + + ) + break + } + + mainControlBarRender = ( +
+ {getModuleCount(props.selectedModules)} + {bulkCollectionActionButton} + {bulkActionButton} + +
+ ) } useEffect(() => { @@ -252,23 +739,6 @@ function Dashboard(props) { 'repository--item-list--collection--item--multi-wrapper ' itemCollectionMultiWrapperClassName += isLoading ? 'fade' : '' - const dashboardTitle = props.showDeletedModules ? 'My Deleted Modules' : 'My Modules' - const actionButtonMultiOperation = props.showDeletedModules ? ( - - ) : ( - - ) - return (
- {props.multiSelectMode ? ( -
- {getModuleCount(props.selectedModules)} - {actionButtonMultiOperation} - -
- ) : ( -
- {props.showDeletedModules ? ( - - ) : ( -
- - - - - - -
- )} - -
- )} -
- {dashboardTitle} -
- Sort - -
+ {mainControlBarRender} +
+ {modulesTitle} + {moduleSortRender}
{isLoading && }
- {moduleList.sort(getSortMethod(sortOrder)).map((draft, index) => ( - handleSelectModule(e, draft.draftId, index)} - key={draft.draftId} - hasMenu={true} - isDeleted={props.showDeletedModules} - {...draft} - /> - ))} + {renderModules( + props.filteredModules ? props.filteredModules : props.myModules, + moduleSortOrder, + newModuleId, + newModuleButtonRender + )} + {allModulesButtonRender}
+ {collectionAreaRender}
{props.dialog ? renderModalDialog(modalProps) : null} diff --git a/packages/app/obojobo-repository/shared/components/dashboard.scss b/packages/app/obojobo-repository/shared/components/dashboard.scss index 1409417e01..fe90851bb2 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard.scss +++ b/packages/app/obojobo-repository/shared/components/dashboard.scss @@ -1,12 +1,18 @@ @import '../../client/css/defaults'; #dashboard-root { + $placeholder-text-color: #aaaaaa; + .repository--main-content--control-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: $size-spacing-vertical-big; + .repository--main-content--new-module-button { + width: 11em; + } + &.is-multi-select-mode { border: 1px solid $color-banner-bg; background-color: $color-banner-bg; @@ -21,6 +27,7 @@ button.secondary-button { margin: 0.3em 0.2em; font-size: 0.65em; + width: 7em; } button.close-button { @@ -74,4 +81,20 @@ } } } + + .repository--item-list--collection--empty-placeholder { + display: block; + margin: 0 auto; + + .repository--item-list--collection--empty-placeholder--text { + color: $placeholder-text-color; + margin-right: 2em; + } + } +} + +.repository--button.repository--all-modules--button { + height: 3em; + font-size: 1em; + margin: auto; } diff --git a/packages/app/obojobo-repository/shared/components/dashboard.test.js b/packages/app/obojobo-repository/shared/components/dashboard.test.js index 87bff05bb7..7235b5dd70 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard.test.js +++ b/packages/app/obojobo-repository/shared/components/dashboard.test.js @@ -1,34 +1,109 @@ +jest.mock('short-uuid') + // mock all of these components so we can check that they're rendered and // run their callbacks without worrying about fully implementing them all +jest.mock('./button', () => props => { + return {props.children} +}) +jest.mock('./button-link', () => props => { + return {props.children} +}) jest.mock('./multi-button', () => props => { return {props.children} }) +jest.mock('./collection', () => props => { + return {props.children} +}) +jest.mock('./module', () => props => { + return {props.children} +}) +jest.mock('./search', () => props => { + return {props.children} +}) jest.mock('react-modal', () => props => { return }) +jest.mock('./collection-manage-modules-dialog', () => props => { + return +}) +jest.mock('./collection-rename-dialog', () => props => { + return +}) +jest.mock('./collection-bulk-add-modules-dialog', () => props => { + return +}) +jest.mock('./module-manage-collections-dialog', () => props => { + return +}) jest.mock('./module-permissions-dialog', () => props => { return }) jest.mock('./module-options-dialog', () => props => { return }) +jest.mock('./assessment-score-data-dialog', () => props => { + return +}) import React from 'react' import { create, act } from 'react-test-renderer' import Dashboard from './dashboard' -import MultiButton from './multi-button' import Button from './button' +import ButtonLink from './button-link' +import MultiButton from './multi-button' +import Collection from './collection' import Module from './module' import Search from './search' import ReactModal from 'react-modal' +import CollectionManageModulesDialog from './collection-manage-modules-dialog' +import CollectionBulkAddModulesDialog from './collection-bulk-add-modules-dialog' +import CollectionRenameDialog from './collection-rename-dialog' +import ModuleManageCollectionsDialog from './module-manage-collections-dialog' import ModulePermissionsDialog from './module-permissions-dialog' import ModuleOptionsDialog from './module-options-dialog' import VersionHistoryDialog from './version-history-dialog' import AssessmentScoreDataDialog from './assessment-score-data-dialog' +const { MODE_RECENT, MODE_ALL, MODE_COLLECTION, MODE_DELETED } = require('../repository-constants') + describe('Dashboard', () => { + const mockShortFromUUID = jest.fn() + + const standardMyCollections = [ + { + id: 'mockCollectionId', + title: 'D Collection Title ', + createdAt: new Date(10000000000).toISOString(), + updatedAt: new Date(200000000000).toISOString() + }, + { + id: 'mockCollectionId2', + title: 'A Collection Title 2', + createdAt: new Date(20000000000).toISOString(), + updatedAt: new Date(400000000000).toISOString() + }, + { + id: 'mockCollectionId3', + title: 'C Collection Title 3', + createdAt: new Date(30000000000).toISOString(), + updatedAt: new Date(300000000000).toISOString() + }, + { + id: 'mockCollectionId4', + title: 'B Collection Title 4', + createdAt: new Date(40000000000).toISOString(), + updatedAt: new Date(100000000000).toISOString() + }, + { + id: 'mockCollectionId5', + title: 'E Collection Title 5', + createdAt: new Date(50000000000).toISOString(), + updatedAt: new Date(500000000000).toISOString() + } + ] + const standardMyModules = [ { draftId: 'mockDraftId', @@ -63,6 +138,7 @@ describe('Dashboard', () => { ] let dashboardProps + let short const originalAlert = global.alert const originalConfirm = window.confirm @@ -106,7 +182,7 @@ describe('Dashboard', () => { 'canPreviewDrafts' ] }, - + mode: MODE_RECENT, dialog: null, selectedModule: {}, draftPermissions: {}, @@ -114,9 +190,11 @@ describe('Dashboard', () => { myModules: [], selectedModules: [], multiSelectMode: false, - sortOrder: 'alphabetical', + moduleSortOrder: 'alphabetical', + collectionSortOrder: 'alphabetical', moduleCount: 0, moduleSearchString: '', + collectionSearchString: '', shareSearchString: '', searchPeople: { hasFetched: false, @@ -139,6 +217,12 @@ describe('Dashboard', () => { getDeletedModules: jest.fn(), bulkRestoreModules: jest.fn(() => Promise.resolve()) } + + short = require('short-uuid') + mockShortFromUUID.mockReturnValue('mockCollectionShortId') + short.mockReturnValue({ + fromUUID: mockShortFromUUID + }) }) afterEach(() => { @@ -151,7 +235,175 @@ describe('Dashboard', () => { window.location.assign = originalLocationAssign }) - const expectDashboardRender = () => { + const expectRecentAndAllModeNewOptions = component => { + const multiButton = component.root.findByType(MultiButton).children[0] + // four buttons and a divider + expect(multiButton.children.length).toBe(5) + expect(multiButton.children[0].children[0].children[0]).toBe('New Collection') + expect(multiButton.children[1].type).toBe('hr') + expect(multiButton.children[2].children[0].children[0]).toBe('New Module') + expect(multiButton.children[3].children[0].children[0]).toBe('New Tutorial') + expect(multiButton.children[4].children[0].children[0]).toBe('Upload...') + } + + const expectRecentModeControlBar = component => { + const expectedControlBarClasses = + 'repository--main-content--control-bar is-not-multi-select-mode' + const controlBar = component.root.findByProps({ className: expectedControlBarClasses }) + + // MODE_RECENT control bar only has the 'New...' button, no module filter + expect(controlBar.children.length).toBe(1) + expect(controlBar.children[0].props.title).toEqual('New...') + expect(component.root.findAllByType(Search).length).toBe(0) + } + + const expectAllModeControlBar = component => { + const expectedControlBarClasses = + 'repository--main-content--control-bar is-not-multi-select-mode' + const controlBar = component.root.findByProps({ className: expectedControlBarClasses }) + + // MODE_ALL control bar has the 'New...' button, 'Deleted Modules' link button and module filter + expect(controlBar.children.length).toBe(3) + expect(controlBar.children[0].props.title).toEqual('New...') + expect(controlBar.children[1].type).toEqual(ButtonLink) + expect(controlBar.children[1].props.url).toBe('/dashboard/deleted') + + expect(component.root.findAllByType(Search).length).toBe(1) + expect(controlBar.children[2].type).toEqual(Search) + } + + const expectCollectionModeControlBar = component => { + const expectedControlBarClasses = + 'repository--main-content--control-bar is-not-multi-select-mode' + const controlBar = component.root.findByProps({ className: expectedControlBarClasses }) + + // MODE_COLLECTION will have the 'New...' button, module filter, and collection management options + expect(controlBar.children.length).toBe(5) + expect(controlBar.children[0].props.title).toEqual('New...') + expect(component.root.findAllByType(Search).length).toBe(1) + + expect(controlBar.children[1].type).toEqual(Button) + expect(controlBar.children[1].props.children).toBe('Manage Modules') + + expect(controlBar.children[2].type).toEqual(Button) + expect(controlBar.children[2].props.children).toBe('Rename') + + expect(controlBar.children[3].type).toEqual(Button) + expect(controlBar.children[3].props.children).toBe('Delete Collection') + + expect(controlBar.children[4].type).toEqual(Search) + } + + const expectDeletedModeControlBar = component => { + const expectedControlBarClasses = + 'repository--main-content--control-bar is-not-multi-select-mode' + const controlBar = component.root.findByProps({ className: expectedControlBarClasses }) + + // MODE_DELETED control bar has the 'Return to All Modules' link button and module filter + expect(controlBar.children.length).toBe(2) + expect(controlBar.children[0].type).toEqual(ButtonLink) + expect(controlBar.children[0].props.url).toBe('/dashboard/all') + + expect(component.root.findAllByType(Search).length).toBe(1) + expect(controlBar.children[1].type).toEqual(Search) + } + + const expectMultiSelectControlBar = ( + component, + isCollectionMode = false, + isDeleteMode = false + ) => { + const expectedControlBarClasses = 'repository--main-content--control-bar is-multi-select-mode' + const controlBar = component.root.findByProps({ className: expectedControlBarClasses }) + + if (isDeleteMode) { + expect(controlBar.children.length).toBe(3) + + expect(controlBar.children[0].props.className).toBe('module-count') + expect(controlBar.children[0].type).toBe('span') + expect(controlBar.children[1].props.children).toEqual('Restore All') + expect(controlBar.children[2].props.className).toEqual('close-button') + expect(controlBar.children[2].children[0].children[0]).toBe('×') + } else { + expect(controlBar.children.length).toBe(4) + + expect(controlBar.children[0].props.className).toBe('module-count') + expect(controlBar.children[0].type).toBe('span') + // In MODE_COLLECTION the bulk action will be to remove modules from the current collection + // In both other modes the bulk action will be to open a dialog to choose a collection to add all modules to + const collectionOperationButtonString = isCollectionMode + ? 'Remove All From Collection' + : 'Add All To Collection' + expect(controlBar.children[1].props.children).toEqual(collectionOperationButtonString) + expect(controlBar.children[2].props.children).toEqual('Delete All') + expect(controlBar.children[3].props.className).toEqual('close-button') + expect(controlBar.children[3].children[0].children[0]).toBe('×') + } + + const moduleComponents = component.root.findAllByType(Module) + expect(moduleComponents.length).toBe(5) + moduleComponents.forEach(moduleComponent => { + expect(moduleComponent.props.isMultiSelectMode).toBe(true) + }) + } + + const getPlaceholderComponents = component => { + const expectedPlaceholderClass = 'repository--item-list--collection--empty-placeholder' + return component.root.findAllByProps({ className: expectedPlaceholderClass }) + } + + const expectCookiePropForPath = (prop, value, path) => { + expect(document.cookie[prop].value).toBe(value) + expect(document.cookie[prop].path).toBe(path) + } + + const expectDialogToBeRendered = (component, dialogComponent, title) => { + expect(ReactModal.setAppElement).toHaveBeenCalledTimes(1) + expect(component.root.findByType(ReactModal).props.contentLabel).toBe(title) + expect(component.root.findAllByType(dialogComponent).length).toBe(1) + } + + const expectMethodToBeCalledOnceWith = (method, calledWith = []) => { + expect(method).toHaveBeenCalledTimes(1) + expect(method.mock.calls[0]).toEqual(calledWith) + method.mockReset() + } + + const expectModeRecentRender = component => { + //numerous changes to check for within the main content area + const mainContent = component.root.findByProps({ className: 'repository--main-content' }) + + expectRecentModeControlBar(component) + + // MODE_RECENT will apply an extra class to the 'My Modules' title + const expectedModulesTitleClasses = + 'repository--main-content--title repository--my-modules-title stretch-width' + expect(mainContent.children[1].props.className).toBe(expectedModulesTitleClasses) + expect(mainContent.children[1].children[0].children[0]).toBe('My Recent Modules') + + // no filters should be on the page since there are no modules or collections to filter + expect(component.root.findAllByType(Search).length).toBe(0) + + // MODE_RECENT will remove the module sorting options + const expectedModuleSortClasses = 'repository--main-content--sort repository--module-sort' + expect(component.root.findAllByProps({ className: expectedModuleSortClasses }).length).toBe(0) + // MODE_RECENT is the only one with a 'My Collections' area + const expectedCollectionsTitleClasses = + 'repository--main-content--title repository--my-collections-title' + expect(mainContent.children[3].props.className).toBe(expectedCollectionsTitleClasses) + + expectRecentAndAllModeNewOptions(component) + + // MODE_RECENT should show a placeholder area for modules or collections if either is empty + const placeholderComponents = getPlaceholderComponents(component) + expect(placeholderComponents.length).toBe(2) + expect(placeholderComponents[0].findByType(Button).children[0].children[0]).toBe('New Module') + expect(placeholderComponents[1].findByType(Button).children[0].children[0]).toBe( + 'New Collection' + ) + } + + const expectModeAllOrModeCollectionRender = cookiePath => { dashboardProps.myModules = [...standardMyModules] const reusableComponent = let component @@ -162,26 +414,13 @@ describe('Dashboard', () => { // the current sort methods for modules and collections are stored in a cookie // this cookie should be set initially when the component first renders // this cookie should also change when a sort method is chosen while 'document' is defined - expectCookiePropForPath('sortOrder', 'alphabetical', '/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 is-not-multi-select-mode' - const controlBar = component.root.findByProps({ className: expectedControlBarClasses }) - - expect(controlBar.children.length).toBe(2) - expect(component.root.findAllByType(Search).length).toBe(1) - - expectNormalModulesAreaClassesWithTitle(mainContent, 'My Modules') + expectCookiePropForPath('moduleSortOrder', 'alphabetical', cookiePath) + expectCookiePropForPath('collectionSortOrder', 'alphabetical', cookiePath) let moduleComponents = component.root.findAllByType(Module) expect(moduleComponents.length).toBe(5) - expectNewModuleOptions(component) - - const expectedModuleSortClass = 'repository--main-content--sort' + const expectedModuleSortClass = 'repository--main-content--sort repository--module-sort' const moduleSortParent = component.root.findByProps({ className: expectedModuleSortClass }) const moduleSort = moduleSortParent.children[1] @@ -199,7 +438,8 @@ describe('Dashboard', () => { component.update(reusableComponent) }) - expectCookiePropForPath('sortOrder', 'newest', '/dashboard') + expectCookiePropForPath('moduleSortOrder', 'newest', cookiePath) + expectCookiePropForPath('collectionSortOrder', 'alphabetical', cookiePath) // changing the sort method should resort modules automatically moduleComponents = component.root.findAllByType(Module) @@ -215,7 +455,8 @@ describe('Dashboard', () => { component.update(reusableComponent) }) - expectCookiePropForPath('sortOrder', 'last updated', '/dashboard') + expectCookiePropForPath('moduleSortOrder', 'last updated', cookiePath) + expectCookiePropForPath('collectionSortOrder', 'alphabetical', cookiePath) moduleComponents = component.root.findAllByType(Module) expect(moduleSort.props.value).toBe('last updated') @@ -228,187 +469,706 @@ 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() + return component } const expectNormalModulesAreaClassesWithTitle = (mainContent, title) => { - const expectedModulesTitleClasses = 'repository--main-content--title' + const expectedModulesTitleClasses = + 'repository--main-content--title repository--my-modules-title ' expect(mainContent.children[1].props.className).toBe(expectedModulesTitleClasses) expect(mainContent.children[1].children[0].children[0]).toBe(title) } - const expectNewModuleOptions = component => { + const expectNonRecentNewModuleOptions = component => { + // MODE_COLLECTION has no 'New Collection' option under the 'New Module +' button const multiButton = component.root.findByType(MultiButton).children[0] - // three child buttons + // four buttons and the 'hr' under the 'new collection' button expect(multiButton.children.length).toBe(3) expect(multiButton.children[0].children[0].children[0]).toBe('New Module') expect(multiButton.children[1].children[0].children[0]).toBe('New Tutorial') 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('×') - } + test('renders with default props', () => { + const component = create() - const expectCookiePropForPath = (prop, value, path) => { - expect(document.cookie[prop].value).toBe(value) - expect(document.cookie[prop].path).toBe(path) - } + // there shouldn't ever be a case where 'mode' is missing + // but the default case is equivalent to MODE_RECENT + expectModeRecentRender(component) - const expectDialogToBeRendered = (component, dialogComponent, title) => { - expect(ReactModal.setAppElement).toHaveBeenCalledTimes(1) - expect(component.root.findByType(ReactModal).props.contentLabel).toBe(title) - expect(component.root.findAllByType(dialogComponent).length).toBe(1) - } + expect(component.toJSON()).toMatchSnapshot() + }) - const expectMethodToBeCalledOnceWith = (method, calledWith = []) => { - expect(method).toHaveBeenCalledTimes(1) - expect(method.mock.calls[0]).toEqual(calledWith) - method.mockReset() - } + test('renders with mode = MODE_RECENT', () => { + dashboardProps.mode = MODE_RECENT + const component = create() - test('renders with default props', () => { - expectDashboardRender() - }) + expectModeRecentRender(component) - test('renders with multiSelectMode=true', () => { - expectMultiSelectDashboardRender() + expect(component.toJSON()).toMatchSnapshot() }) - test('renders filtered modules properly', () => { - dashboardProps.myModules = [...standardMyModules] - dashboardProps.filterModules = jest.fn() - let component - act(() => { - component = create() - }) + test('renders with mode = MODE_ALL', () => { + dashboardProps.mode = MODE_ALL + const component = create() - let moduleComponents = component.root.findAllByType(Module) - expect(moduleComponents.length).toBe(5) - expect(moduleComponents[0].props.draftId).toBe('mockDraftId2') - expect(moduleComponents[1].props.draftId).toBe('mockDraftId4') - expect(moduleComponents[2].props.draftId).toBe('mockDraftId3') - expect(moduleComponents[3].props.draftId).toBe('mockDraftId') - expect(moduleComponents[4].props.draftId).toBe('mockDraftId5') + //numerous changes to check for within the main content area + const mainContent = component.root.findByProps({ className: 'repository--main-content' }) + //some in the control bar + expectAllModeControlBar(component) - // changing the text of the search field should call props.filterModules - const filterChangePayload = { target: { value: 'string' } } - component.root.findByType(Search).props.onChange(filterChangePayload) - expect(dashboardProps.filterModules).toHaveBeenCalledTimes(1) - expect(dashboardProps.filterModules).toHaveBeenCalledWith(filterChangePayload) + // MODE_ALL will not apply an extra class to the 'My Modules' title + expectNormalModulesAreaClassesWithTitle(mainContent, 'My Modules') - // normally props.filteredModules would be set in a reducer at the end of - // a chain of methods starting with props.filterModules - // here we can just set the prop manually and see what happens - act(() => { - dashboardProps.filteredModules = [ - { - draftId: 'mockDraftId3', - title: 'C Module Title 3', - createdAt: new Date(30000000000).toISOString(), - updatedAt: new Date(300000000000).toISOString() - }, - { - draftId: 'mockDraftId4', - title: 'B Module Title 4', - createdAt: new Date(40000000000).toISOString(), - updatedAt: new Date(100000000000).toISOString() - } - ] - component.update() - }) + // MODE_ALL does not render the 'All Modules' button, but it does render a 'Deleted Modules' button + const buttonLinkComponents = component.root.findAllByType(ButtonLink) + expect(buttonLinkComponents.length).toBe(1) - // should also still be sorting them alphabetically by default - moduleComponents = component.root.findAllByType(Module) - expect(moduleComponents.length).toBe(2) - expect(moduleComponents[0].props.draftId).toBe('mockDraftId4') - expect(moduleComponents[1].props.draftId).toBe('mockDraftId3') + expect(buttonLinkComponents[0].type).toEqual(ButtonLink) + expect(buttonLinkComponents[0].props.url).toBe('/dashboard/deleted') - component.unmount() + expectRecentAndAllModeNewOptions(component) + + expect(component.toJSON()).toMatchSnapshot() }) - test('"New Module" and "Upload..." buttons call functions appropriately', async () => { - const newModule = { - payload: { - value: [ - { - draftId: 'mockId1', - createdAt: 1 - }, - { - draftId: 'mockId2', - createdAt: 2 - } - ] - } + test('renders with mode = MODE_COLLECTION', () => { + dashboardProps.mode = MODE_COLLECTION + dashboardProps.collection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' } - dashboardProps.createNewModule = jest.fn() - dashboardProps.importModuleFile = jest.fn() const component = create() - const setNewModuleId = jest.fn() - const handleClick = jest.spyOn(React, 'useState') - handleClick.mockImplementation(newModuleId => [newModuleId, setNewModuleId]) + //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 is-not-multi-select-mode' + const controlBar = component.root.findByProps({ className: expectedControlBarClasses }) + // MODE_COLLECTION should have a lot in the control bar: + // 'New Module +' button and various controls for the current collection + expect(controlBar.children.length).toBe(5) + // three buttons in the 'New Module +' multibutton and three for managing the current collection + expect(controlBar.findAllByType(Button).length).toBe(6) + expect(component.root.findAllByType(Search).length).toBe(1) - // three buttons under the 'New Module +' MultiButton component - const multiButton = component.root.findByType(MultiButton).children[0] + // MODE_COLLECTION will not apply an extra class to the 'My Modules' title + expectNormalModulesAreaClassesWithTitle(mainContent, "Modules in 'Mock Collection Title'") - // 'New Module' button should call createNewModule with false - expect(multiButton.children[0].children[0].children[0]).toBe('New Module') - dashboardProps.createNewModule.mockResolvedValue(newModule) - await act(async () => { - multiButton.children[0].props.onClick() - }) + // MODE_COLLECTION does not render the 'All Modules' button + expect(component.root.findAllByType(ButtonLink).length).toBe(0) - expect(dashboardProps.createNewModule).toHaveBeenCalledTimes(1) - expect(setNewModuleId).toBeTruthy() - dashboardProps.createNewModule.mockReset() + expectNonRecentNewModuleOptions(component) - // 'New Tutorial' button should call createNewModule with true - expect(multiButton.children[1].children[0].children[0]).toBe('New Tutorial') - dashboardProps.createNewModule.mockResolvedValue(newModule) - await act(async () => { - multiButton.children[1].props.onClick() + expect(component.toJSON()).toMatchSnapshot() + }) + + test('renders in MODE_RECENT with fewer modules than moduleCount, no collections', () => { + dashboardProps.mode = MODE_RECENT + // putting these in non-sequential order to test MODE_RECENT sorting + dashboardProps.myModules = [...standardMyModules] + dashboardProps.moduleCount = 10 + // moduleSortOrder should always be 'last updated' for MODE_RECENT dashboards + dashboardProps.moduleSortOrder = 'last updated' + let component + act(() => { + component = create() }) - expect(dashboardProps.createNewModule).toHaveBeenCalledTimes(1) - expect(setNewModuleId).toBeTruthy() - dashboardProps.createNewModule.mockReset() - // 'Upload...' button should call importModuleFile with no arguments - expect(multiButton.children[2].children[0].children[0]).toBe('Upload...') + const moduleComponents = component.root.findAllByType(Module) + expect(moduleComponents.length).toBe(5) + + // MODE_RECENT should always sort according to updated_date in descending order + expect(moduleComponents[0].props.draftId).toBe('mockDraftId5') + expect(moduleComponents[1].props.draftId).toBe('mockDraftId2') + expect(moduleComponents[2].props.draftId).toBe('mockDraftId3') + expect(moduleComponents[3].props.draftId).toBe('mockDraftId') + expect(moduleComponents[4].props.draftId).toBe('mockDraftId4') + + // MODE_RECENT should display the 'All Modules' button + expect(component.root.findAllByType(ButtonLink).length).toBe(1) + const allModulesButton = component.root.findByType(ButtonLink) + // it should also have the same parent as the module components + expect(allModulesButton.parent).toStrictEqual(moduleComponents[0].parent.parent) + + // MODE_RECENT without any collections should render a placeholder in the collections list + const placeholderComponents = getPlaceholderComponents(component) + expect(placeholderComponents.length).toBe(1) + expect(placeholderComponents[0].findByType(Button).children[0].children[0]).toBe( + 'New Collection' + ) + // and no collection filter + expect(component.root.findAllByType(Search).length).toBe(0) + + expect(component.toJSON()).toMatchSnapshot() + }) + + test('renders in MODE_RECENT with collections and sorts them correctly', () => { + dashboardProps.mode = MODE_RECENT + // module sort order for MODE_RECENT is explicitly set to 'last updated' in express + dashboardProps.moduleSortOrder = 'last updated' + // putting these in non-sequential order to test MODE_RECENT sorting + dashboardProps.myCollections = [...standardMyCollections] + const reusableComponent = + let component + act(() => { + component = create(reusableComponent) + }) + + // the current sort methods for modules and collections are stored in a cookie + // this cookie should be set initially when the component first renders + // this cookie should also change when a sort method is chosen while 'document' is defined + expectCookiePropForPath('moduleSortOrder', 'last updated', '/dashboard') + expectCookiePropForPath('collectionSortOrder', 'alphabetical', '/dashboard') + + let collectionComponents = component.root.findAllByType(Collection) + expect(collectionComponents.length).toBe(5) + + const expectedCollectionSortClass = 'repository--main-content--sort repository--collection-sort' + const collectionSortParent = component.root.findByProps({ + className: expectedCollectionSortClass + }) + const collectionSort = collectionSortParent.children[1] + + // default sort method should be 'alphabetical' + expect(collectionSort.props.value).toBe('alphabetical') + expect(collectionComponents[0].props.id).toBe('mockCollectionId2') + expect(collectionComponents[1].props.id).toBe('mockCollectionId4') + expect(collectionComponents[2].props.id).toBe('mockCollectionId3') + expect(collectionComponents[3].props.id).toBe('mockCollectionId') + expect(collectionComponents[4].props.id).toBe('mockCollectionId5') + + // sort order should change when the drop-down changes + act(() => { + collectionSort.props.onChange({ target: { value: 'newest' } }) + component.update(reusableComponent) + }) + + expectCookiePropForPath('moduleSortOrder', 'last updated', '/dashboard') + expectCookiePropForPath('collectionSortOrder', 'newest', '/dashboard') + + // changing the sort method should resort collections automatically + collectionComponents = component.root.findAllByType(Collection) + expect(collectionSort.props.value).toBe('newest') + expect(collectionComponents[0].props.id).toBe('mockCollectionId5') + expect(collectionComponents[1].props.id).toBe('mockCollectionId4') + expect(collectionComponents[2].props.id).toBe('mockCollectionId3') + expect(collectionComponents[3].props.id).toBe('mockCollectionId2') + expect(collectionComponents[4].props.id).toBe('mockCollectionId') + + act(() => { + collectionSort.props.onChange({ target: { value: 'last updated' } }) + component.update(reusableComponent) + }) + + expectCookiePropForPath('moduleSortOrder', 'last updated', '/dashboard') + expectCookiePropForPath('collectionSortOrder', 'last updated', '/dashboard') + + collectionComponents = component.root.findAllByType(Collection) + expect(collectionSort.props.value).toBe('last updated') + expect(collectionComponents[0].props.id).toBe('mockCollectionId5') + expect(collectionComponents[1].props.id).toBe('mockCollectionId2') + expect(collectionComponents[2].props.id).toBe('mockCollectionId3') + expect(collectionComponents[3].props.id).toBe('mockCollectionId') + expect(collectionComponents[4].props.id).toBe('mockCollectionId4') + }) + + test('renders filtered collections in MODE_RECENT properly', () => { + dashboardProps.mode = MODE_RECENT + // putting these in non-sequential order to test MODE_RECENT sorting + dashboardProps.myCollections = [...standardMyCollections] + dashboardProps.filterCollections = jest.fn() + let component + act(() => { + component = create() + }) + + let collectionComponents = component.root.findAllByType(Collection) + expect(collectionComponents.length).toBe(5) + expect(collectionComponents[0].props.id).toBe('mockCollectionId2') + expect(collectionComponents[1].props.id).toBe('mockCollectionId4') + expect(collectionComponents[2].props.id).toBe('mockCollectionId3') + expect(collectionComponents[3].props.id).toBe('mockCollectionId') + expect(collectionComponents[4].props.id).toBe('mockCollectionId5') + + // changing the text of the search field should call props.filterCollections + const filterChangePayload = { target: { value: 'string' } } + component.root.findByType(Search).props.onChange(filterChangePayload) + expect(dashboardProps.filterCollections).toHaveBeenCalledTimes(1) + expect(dashboardProps.filterCollections).toHaveBeenCalledWith(filterChangePayload) + + // normally props.filteredCollections would be set in a reducer at the end of + // a chain of methods starting with props.filterCollections + // here we can just set the prop manually and see what happens + act(() => { + dashboardProps.filteredCollections = [ + { + id: 'mockCollectionId3', + title: 'C Collection Title 3', + createdAt: new Date(30000000000).toISOString(), + updatedAt: new Date(300000000000).toISOString() + }, + { + id: 'mockCollectionId4', + title: 'B Collection Title 4', + createdAt: new Date(40000000000).toISOString(), + updatedAt: new Date(100000000000).toISOString() + } + ] + component.update() + }) + + // should also still be sorting them alphabetically by default + collectionComponents = component.root.findAllByType(Collection) + expect(collectionComponents.length).toBe(2) + expect(collectionComponents[0].props.id).toBe('mockCollectionId4') + expect(collectionComponents[1].props.id).toBe('mockCollectionId3') + }) + + test('renders in MODE_ALL with modules and sorts them correctly', () => { + dashboardProps.mode = MODE_ALL + + const component = expectModeAllOrModeCollectionRender('/dashboard/all') + expectAllModeControlBar(component) + + component.unmount() + }) + + test('renders filtered modules in MODE_ALL properly', () => { + dashboardProps.mode = MODE_ALL + // putting these in non-sequential order to test MODE_ALL sorting + dashboardProps.myModules = [...standardMyModules] + dashboardProps.filterModules = jest.fn() + let component + act(() => { + component = create() + }) + + let moduleComponents = component.root.findAllByType(Module) + expect(moduleComponents.length).toBe(5) + expect(moduleComponents[0].props.draftId).toBe('mockDraftId2') + expect(moduleComponents[1].props.draftId).toBe('mockDraftId4') + expect(moduleComponents[2].props.draftId).toBe('mockDraftId3') + expect(moduleComponents[3].props.draftId).toBe('mockDraftId') + expect(moduleComponents[4].props.draftId).toBe('mockDraftId5') + + // changing the text of the search field should call props.filterModules + const filterChangePayload = { target: { value: 'string' } } + component.root.findByType(Search).props.onChange(filterChangePayload) + expect(dashboardProps.filterModules).toHaveBeenCalledTimes(1) + expect(dashboardProps.filterModules).toHaveBeenCalledWith(filterChangePayload) + + // normally props.filteredModules would be set in a reducer at the end of + // a chain of methods starting with props.filterModules + // here we can just set the prop manually and see what happens + act(() => { + dashboardProps.filteredModules = [ + { + draftId: 'mockDraftId3', + title: 'C Module Title 3', + createdAt: new Date(30000000000).toISOString(), + updatedAt: new Date(300000000000).toISOString() + }, + { + draftId: 'mockDraftId4', + title: 'B Module Title 4', + createdAt: new Date(40000000000).toISOString(), + updatedAt: new Date(100000000000).toISOString() + } + ] + component.update() + }) + + // should also still be sorting them alphabetically by default + moduleComponents = component.root.findAllByType(Module) + expect(moduleComponents.length).toBe(2) + expect(moduleComponents[0].props.draftId).toBe('mockDraftId4') + expect(moduleComponents[1].props.draftId).toBe('mockDraftId3') + + component.unmount() + }) + + // MODE_COLLECTION and MODE_ALL handle module rendering/sorting the same way + // the only difference is the path attached to cookies + test('renders in MODE_COLLECTION with modules and sorts them correctly', () => { + dashboardProps.mode = MODE_COLLECTION + dashboardProps.collection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' + } + const component = expectModeAllOrModeCollectionRender( + '/collections/mock-collection-title-mockCollectionShortId' + ) + expectCollectionModeControlBar(component) + + // MODE_COLLECTION sort order cookies will use shortUUID to generate cookie paths + // three calls - one on the initial render, then one for each of the two sort order changes + expect(mockShortFromUUID).toHaveBeenCalledTimes(3) + mockShortFromUUID.mock.calls.forEach(call => expect(call[0]).toBe('mockCollectionId')) + + component.unmount() + }) + + // This is less repetitive but maybe should be individual tests per mode + test('renders with multiSelectMode=true, no selected modules, in all modes', () => { + dashboardProps.myModules = [...standardMyModules] + dashboardProps.multiSelectMode = true + + let component + act(() => { + component = create() + }) + + // module list should be the same regardless of mode + const moduleComponents = component.root.findAllByType(Module) + expect(moduleComponents.length).toBe(5) + moduleComponents.forEach(moduleComponent => { + expect(moduleComponent.props.isMultiSelectMode).toBe(true) + }) + + // no mode - shouldn't ever happen, but should be the same as 'recent' mode + expectRecentModeControlBar(component) + + dashboardProps.mode = MODE_RECENT + act(() => { + component = create() + }) + expectRecentModeControlBar(component) + + dashboardProps.mode = MODE_ALL + act(() => { + component = create() + }) + expectAllModeControlBar(component) + + dashboardProps.mode = MODE_DELETED + act(() => { + component = create() + }) + expectDeletedModeControlBar(component) + + dashboardProps.mode = MODE_COLLECTION + dashboardProps.collection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' + } + act(() => { + component = create() + }) + expectCollectionModeControlBar(component) + + component.unmount() + }) + + test('renders with multiSelectMode=true, some selected modules, in all modes', () => { + dashboardProps.myModules = [...standardMyModules] + dashboardProps.multiSelectMode = true + dashboardProps.selectedModules = [standardMyModules[0].draftId, standardMyModules[1].draftId] + let component + act(() => { + component = create() + }) + + // no mode - shouldn't ever happen, but should be the same as 'recent' mode + expectMultiSelectControlBar(component) + + dashboardProps.mode = MODE_RECENT + act(() => { + component = create() + }) + expectMultiSelectControlBar(component) + + dashboardProps.mode = MODE_ALL + act(() => { + component = create() + }) + expectMultiSelectControlBar(component) + + dashboardProps.mode = MODE_DELETED + act(() => { + component = create() + }) + expectMultiSelectControlBar(component, false, true) + + dashboardProps.mode = MODE_COLLECTION + dashboardProps.collection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' + } + act(() => { + component = create() + }) + expectMultiSelectControlBar(component, true) + + component.unmount() + }) + + test('renders selected module count properly when one or multiple modules are selected', () => { + dashboardProps.myModules = [...standardMyModules] + dashboardProps.multiSelectMode = true + dashboardProps.selectedModules = [standardMyModules[0].draftId, standardMyModules[1].draftId] + let component + act(() => { + component = create() + }) + + const expectedControlBarClasses = 'repository--main-content--control-bar is-multi-select-mode' + let controlBar = component.root.findByProps({ className: expectedControlBarClasses }) + + expect(controlBar.children[0].children[0]).toEqual('2 Modules Selected:') + + dashboardProps.selectedModules = [standardMyModules[0].draftId] + act(() => { + component = create() + }) + + controlBar = component.root.findByProps({ className: expectedControlBarClasses }) + expect(controlBar.children[0].children[0]).toEqual('1 Module Selected:') + + component.unmount() + }) + + // Module filter only appears in 'all' and 'collection' modes - reusable function so we can check both + const expectModuleFiltrationWorks = () => { + dashboardProps.myModules = [...standardMyModules] + dashboardProps.filterModules = jest.fn() + let component + act(() => { + component = create() + }) + + let moduleComponents = component.root.findAllByType(Module) + expect(moduleComponents.length).toBe(5) + expect(moduleComponents[0].props.draftId).toBe('mockDraftId2') + expect(moduleComponents[1].props.draftId).toBe('mockDraftId4') + expect(moduleComponents[2].props.draftId).toBe('mockDraftId3') + expect(moduleComponents[3].props.draftId).toBe('mockDraftId') + expect(moduleComponents[4].props.draftId).toBe('mockDraftId5') + + // changing the text of the search field should call props.filterModules + const filterChangePayload = { target: { value: 'string' } } + component.root.findByType(Search).props.onChange(filterChangePayload) + expect(dashboardProps.filterModules).toHaveBeenCalledTimes(1) + expect(dashboardProps.filterModules).toHaveBeenCalledWith(filterChangePayload) + + // normally props.filteredModules would be set in a reducer at the end of + // a chain of methods starting with props.filterModules + // here we can just set the prop manually and see what happens + act(() => { + dashboardProps.filteredModules = [ + { + draftId: 'mockDraftId3', + title: 'C Module Title 3', + createdAt: new Date(30000000000).toISOString(), + updatedAt: new Date(300000000000).toISOString() + }, + { + draftId: 'mockDraftId4', + title: 'B Module Title 4', + createdAt: new Date(40000000000).toISOString(), + updatedAt: new Date(100000000000).toISOString() + } + ] + component.update() + }) + + // should also still be sorting them alphabetically by default + moduleComponents = component.root.findAllByType(Module) + expect(moduleComponents.length).toBe(2) + expect(moduleComponents[0].props.draftId).toBe('mockDraftId4') + expect(moduleComponents[1].props.draftId).toBe('mockDraftId3') + + component.unmount() + } + test('renders filtered modules properly - "all" mode', () => { + dashboardProps.mode = MODE_ALL + expectModuleFiltrationWorks() + }) + test('renders filtered modules properly - "collection" mode', () => { + dashboardProps.mode = MODE_COLLECTION + dashboardProps.collection = { + id: 'mockCollectionId1', + title: 'Mock Collection' + } + expectModuleFiltrationWorks() + }) + + test('"New Collection", "New Module" and "Upload..." buttons call functions appropriately', async () => { + const newModuleMockPayload = [ + { + draftId: 'mockId1', + createdAt: 1 + }, + { + draftId: 'mockId2', + createdAt: 2 + } + ] + const newModule = { + payload: { + value: { + modules: newModuleMockPayload, + allCount: newModuleMockPayload.length + } + } + } + + dashboardProps.mode = MODE_RECENT + dashboardProps.createNewCollection = jest.fn() + dashboardProps.createNewModule = jest.fn() + dashboardProps.importModuleFile = jest.fn() + const component = create() + // four buttons under the 'New Module +' MultiButton component + const multiButton = component.root.findByType(MultiButton).children[0] + + // 'New Collection' button should call createNewCollection with no arguments + expect(multiButton.children[0].children[0].children[0]).toBe('New Collection') + await act(async () => { + multiButton.children[0].props.onClick() + }) + expect(dashboardProps.createNewCollection).toHaveBeenCalledTimes(1) + expect(dashboardProps.createNewCollection).toHaveBeenCalledWith() + dashboardProps.createNewCollection.mockReset() + + // 'New Module' buttons will also pass extra arguments depending on dashboard mode + // in the case of MODE_RECENT, this extra argument will just be an object with 'mode' + // 'New Module' button should call createNewModule with false and extra args + expect(multiButton.children[2].children[0].children[0]).toBe('New Module') + dashboardProps.createNewModule.mockResolvedValue(newModule) await act(async () => { multiButton.children[2].props.onClick() }) + expect(dashboardProps.createNewModule).toHaveBeenCalledTimes(1) + expect(dashboardProps.createNewModule).toHaveBeenCalledWith(false, { mode: MODE_RECENT }) + dashboardProps.createNewModule.mockReset() + + // 'New Tutorial' button should call createNewModule with true and extra args + expect(multiButton.children[3].children[0].children[0]).toBe('New Tutorial') + dashboardProps.createNewModule.mockResolvedValue(newModule) + await act(async () => { + multiButton.children[3].props.onClick() + }) + expect(dashboardProps.createNewModule).toHaveBeenCalledTimes(1) + expect(dashboardProps.createNewModule).toHaveBeenCalledWith(true, { mode: MODE_RECENT }) + dashboardProps.createNewModule.mockReset() + + // 'Upload...' button should call importModuleFile with no arguments + expect(multiButton.children[4].children[0].children[0]).toBe('Upload...') + await act(async () => { + multiButton.children[4].props.onClick() + }) expect(dashboardProps.importModuleFile).toHaveBeenCalledTimes(1) dashboardProps.importModuleFile.mockReset() - handleClick.mockRestore() + // two buttons in placeholders - one for collections and one for modules + const placeholderComponents = getPlaceholderComponents(component) + expect(placeholderComponents.length).toBe(2) + expect(placeholderComponents[0].findByType(Button).children[0].children[0]).toBe('New Module') + expect(placeholderComponents[1].findByType(Button).children[0].children[0]).toBe( + 'New Collection' + ) + + // 'New Module' button should call createNewModule with false and extra args + dashboardProps.createNewModule.mockResolvedValue(newModule) + await act(async () => { + placeholderComponents[0].findByType(Button).props.onClick() + }) + expect(dashboardProps.createNewModule).toHaveBeenCalledTimes(1) + expect(dashboardProps.createNewModule).toHaveBeenCalledWith(false, { mode: MODE_RECENT }) + dashboardProps.createNewModule.mockReset() + + // 'New Collection' button should call createNewCollection with no arguments + await act(async () => { + placeholderComponents[1].findByType(Button).props.onClick() + }) + expect(dashboardProps.createNewCollection).toHaveBeenCalledTimes(1) + dashboardProps.createNewCollection.mockReset() + }) + + test('"Add All To Collection" button calls functions appropriately', () => { + const mockSelectedModules = ['mockId', 'mockId2'] + + dashboardProps.showCollectionBulkAddModulesDialog = jest.fn(() => Promise.resolve()) + + dashboardProps.myModules = [...standardMyModules] + dashboardProps.multiSelectMode = true + dashboardProps.selectedModules = mockSelectedModules + + let component + act(() => { + component = create() + }) + + const expectedControlBarClasses = 'repository--main-content--control-bar is-multi-select-mode' + const controlBar = component.root.findByProps({ className: expectedControlBarClasses }) + + const mockClickEvent = { + preventDefault: jest.fn() + } + + // Delete All button will be second option in 'recent' and 'all' modes + const addAllButton = controlBar.findAllByType(Button)[0] + expect(addAllButton.children[0].children[0]).toBe('Add All To Collection') + + act(() => { + addAllButton.props.onClick(mockClickEvent) + }) + expect(dashboardProps.showCollectionBulkAddModulesDialog).toHaveBeenCalledTimes(1) + expect(dashboardProps.showCollectionBulkAddModulesDialog).toHaveBeenCalledWith( + mockSelectedModules + ) + + component.unmount() + }) + + // This will only be available in MODE_COLLECTION + test('"Remove All From Collection" button calls functions appropriately', async () => { + const mockSelectedModules = ['mockId', 'mockId2'] + + dashboardProps.bulkRemoveModulesFromCollection = jest.fn(() => Promise.resolve()) + + dashboardProps.myModules = [...standardMyModules] + dashboardProps.multiSelectMode = true + dashboardProps.selectedModules = mockSelectedModules + + dashboardProps.mode = MODE_COLLECTION + dashboardProps.collection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' + } + let component + act(() => { + component = create() + }) + + const expectedControlBarClasses = 'repository--main-content--control-bar is-multi-select-mode' + const controlBar = component.root.findByProps({ className: expectedControlBarClasses }) + + const mockClickEvent = { + preventDefault: jest.fn() + } + + // Delete All button will be second option in 'recent' and 'all' modes + const removeAllButton = controlBar.findAllByType(Button)[0] + expect(removeAllButton.children[0].children[0]).toBe('Remove All From Collection') + + window.prompt = jest.fn() + window.prompt.mockReturnValueOnce('NOT REMOVE') + await act(async () => { + removeAllButton.props.onClick(mockClickEvent) + }) + expect(dashboardProps.bulkRemoveModulesFromCollection).not.toHaveBeenCalled() + + window.prompt.mockReturnValueOnce('REMOVE') + await act(async () => { + removeAllButton.props.onClick(mockClickEvent) + }) + expect(dashboardProps.bulkRemoveModulesFromCollection).toHaveBeenCalledTimes(1) + expect(dashboardProps.bulkRemoveModulesFromCollection).toHaveBeenCalledWith( + mockSelectedModules, + 'mockCollectionId' + ) component.unmount() }) @@ -427,7 +1187,8 @@ describe('Dashboard', () => { preventDefault: jest.fn() } - const deleteAllButton = component.root.findAllByType(Button)[0] + // Delete All button will be second option in 'recent' and 'all' modes + const deleteAllButton = component.root.findAllByType(Button)[1] expect(deleteAllButton.children[0].children[0]).toBe('Delete All') window.prompt = jest.fn() @@ -456,7 +1217,8 @@ describe('Dashboard', () => { component = create(reusableComponent) }) - const deselectAllButton = component.root.findAllByType(Button)[1] + // cancel button will be third option in 'recent' and 'all' modes + const deselectAllButton = component.root.findAllByType(Button)[2] expect(deselectAllButton.children[0].children[0]).toBe('×') act(() => { @@ -531,9 +1293,99 @@ describe('Dashboard', () => { component.unmount() }) - test('selecting modules with shift calls functions appropriately', () => { + test("shift-clicking a module's parent container with no other modules selected or preselected does not select any modules", () => { + dashboardProps.myModules = [...standardMyModules] + dashboardProps.selectModules = jest.fn() + let component + act(() => { + component = create() + }) + + // Module components are wrapped with a parent div that catches click events to handle shift-click selection + const moduleComponentParents = component.root.findAllByProps({ + className: 'repository--item-list--collection--module-container' + }) + + act(() => { + const mockClickEvent = { + shiftKey: true + } + moduleComponentParents[2].props.onClick(mockClickEvent) + }) + expect(dashboardProps.selectModules).toHaveBeenCalledTimes(0) + dashboardProps.selectModules.mockReset() + + component.unmount() + }) + + // calling a module's 'onSelect' is different from shift-clicking its parent + test('shift-clicking a module with no other modules selected or preselected selects that module', () => { + dashboardProps.myModules = [...standardMyModules] + dashboardProps.selectModules = jest.fn() + let component + act(() => { + component = create() + }) + + // Module components are wrapped with a parent div that catches click events to handle shift-click selection + 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(['mockDraftId3']) + dashboardProps.selectModules.mockReset() + + component.unmount() + }) + + test('shift-clicking a module after preselecting another module selects both modules plus any in between', () => { + dashboardProps.myModules = [...standardMyModules] + dashboardProps.selectModules = jest.fn() + let component + act(() => { + component = create() + }) + + // Module components are wrapped with a parent div that catches click events to handle shift-click selection + const moduleComponentParents = component.root.findAllByProps({ + className: 'repository--item-list--collection--module-container' + }) + + act(() => { + const mockClickEvent = { + shiftKey: true + } + moduleComponentParents[2].props.onClick(mockClickEvent) + }) + expect(dashboardProps.selectModules).toHaveBeenCalledTimes(0) + dashboardProps.selectModules.mockReset() + + const moduleComponents = component.root.findAllByType(Module) + + // make sure that clicking earlier in the module list works properly + act(() => { + const mockClickEvent = { + shiftKey: true + } + moduleComponents[1].props.onSelect(mockClickEvent) + }) + expect(dashboardProps.selectModules).toHaveBeenCalledTimes(1) + // this seems a bit magical without knowing the sort order based on last updated times - maybe replace with some variables and math later + expect(dashboardProps.selectModules).toHaveBeenCalledWith(['mockDraftId4', 'mockDraftId3']) + dashboardProps.selectModules.mockReset() + + component.unmount() + }) + + test('shift-clicking a module when modules are already selected properly selects additonal modules', () => { dashboardProps.myModules = [...standardMyModules] dashboardProps.selectModules = jest.fn() + dashboardProps.selectedModules = ['mockDraftId4'] let component act(() => { component = create() @@ -541,20 +1393,6 @@ describe('Dashboard', () => { 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 @@ -562,50 +1400,105 @@ describe('Dashboard', () => { moduleComponents[1].props.onSelect(mockClickEvent) }) expect(dashboardProps.selectModules).toHaveBeenCalledTimes(1) - expect(dashboardProps.selectModules).toHaveBeenCalledWith(['mockDraftId4', 'mockDraftId3']) - - component.unmount() + // mockDraftId4 is already in 'selectedModules', so it will not be passed to 'selectModules' + expect(dashboardProps.selectModules).toHaveBeenCalledWith(['mockDraftId2']) + dashboardProps.selectModules.mockReset() }) - test('renders "Module Options" dialog', () => { + test('renders "Module Options" dialog and adjusts callbacks for each mode', () => { dashboardProps.showModuleManageCollections = jest.fn() dashboardProps.showModulePermissions = jest.fn() dashboardProps.deleteModule = jest.fn() dashboardProps.startLoadingAnimation = jest.fn() dashboardProps.stopLoadingAnimation = jest.fn() dashboardProps.dialog = 'module-more' - const component = create() + dashboardProps.mode = MODE_RECENT + let component + act(() => { + component = create() + }) expectDialogToBeRendered(component, ModuleOptionsDialog, 'Module Options') const dialogComponent = component.root.findByType(ModuleOptionsDialog) + dialogComponent.props.showModuleManageCollections() + expectMethodToBeCalledOnceWith(dashboardProps.showModuleManageCollections) dialogComponent.props.showModulePermissions() expectMethodToBeCalledOnceWith(dashboardProps.showModulePermissions) // draftId for the menu's module would normally be passed here dialogComponent.props.deleteModule('mockDraftId') - expectMethodToBeCalledOnceWith(dashboardProps.deleteModule, ['mockDraftId']) + expectMethodToBeCalledOnceWith(dashboardProps.deleteModule, [ + 'mockDraftId', + { mode: MODE_RECENT } + ]) + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + dashboardProps.mode = MODE_ALL act(() => { - dialogComponent.props.startLoadingAnimation() - dialogComponent.props.stopLoadingAnimation() + component.update() }) + dialogComponent.props.showModuleManageCollections() + expectMethodToBeCalledOnceWith(dashboardProps.showModuleManageCollections) + dialogComponent.props.showModulePermissions() + expectMethodToBeCalledOnceWith(dashboardProps.showModulePermissions) + // draftId for the menu's module would normally be passed here + dialogComponent.props.deleteModule('mockDraftId') + expectMethodToBeCalledOnceWith(dashboardProps.deleteModule, ['mockDraftId', { mode: MODE_ALL }]) dialogComponent.props.onClose() expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + dashboardProps.mode = MODE_COLLECTION + dashboardProps.collection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' + } + act(() => { + component.update() + }) + + dialogComponent.props.showModuleManageCollections() + expectMethodToBeCalledOnceWith(dashboardProps.showModuleManageCollections) + dialogComponent.props.showModulePermissions() + expectMethodToBeCalledOnceWith(dashboardProps.showModulePermissions) + // draftId for the menu's module would normally be passed here + dialogComponent.props.deleteModule('mockDraftId') + expectMethodToBeCalledOnceWith(dashboardProps.deleteModule, [ + 'mockDraftId', + { collectionId: 'mockCollectionId', mode: MODE_COLLECTION } + ]) dialogComponent.props.onClose() expectMethodToBeCalledOnceWith(dashboardProps.closeModal) - component.unmount() + const notLoadingClass = 'repository--item-list--collection--item--multi-wrapper ' + const isLoadingClass = 'repository--item-list--collection--item--multi-wrapper fade' + + // make sure loading states are set/unset properly - maybe should be its own test? + act(() => { + dialogComponent.props.startLoadingAnimation() + }) + expect(component.root.findAllByProps({ className: notLoadingClass }).length).toBe(0) + expect(component.root.findAllByProps({ className: isLoadingClass }).length).toBe(1) + + act(() => { + dialogComponent.props.stopLoadingAnimation() + }) + expect(component.root.findAllByProps({ className: notLoadingClass }).length).toBe(1) + expect(component.root.findAllByProps({ className: isLoadingClass }).length).toBe(0) }) - test('renders "Module Access" dialog', () => { + test('renders "Module Access" dialog and adjusts callbacks for each mode', () => { dashboardProps.dialog = 'module-permissions' dashboardProps.loadUsersForModule = jest.fn() dashboardProps.addUserToModule = jest.fn() dashboardProps.draftPermissions = jest.fn() dashboardProps.deleteModulePermissions = jest.fn() - const component = create() + dashboardProps.mode = MODE_RECENT + let component + act(() => { + component = create() + }) expectDialogToBeRendered(component, ModulePermissionsDialog, 'Module Access') const dialogComponent = component.root.findByType(ModulePermissionsDialog) @@ -615,8 +1508,49 @@ describe('Dashboard', () => { dialogComponent.props.addUserToModule('mockDraftId', 99) expectMethodToBeCalledOnceWith(dashboardProps.addUserToModule, ['mockDraftId', 99]) dialogComponent.props.deleteModulePermissions('mockDraftId', 99) - expectMethodToBeCalledOnceWith(dashboardProps.deleteModulePermissions, ['mockDraftId', 99]) + expectMethodToBeCalledOnceWith(dashboardProps.deleteModulePermissions, [ + 'mockDraftId', + 99, + { mode: MODE_RECENT } + ]) + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + + dashboardProps.mode = MODE_ALL + act(() => { + component.update() + }) + dialogComponent.props.loadUsersForModule('mockDraftId') + expectMethodToBeCalledOnceWith(dashboardProps.loadUsersForModule, ['mockDraftId']) + dialogComponent.props.addUserToModule('mockDraftId', 99) + expectMethodToBeCalledOnceWith(dashboardProps.addUserToModule, ['mockDraftId', 99]) + dialogComponent.props.deleteModulePermissions('mockDraftId', 99) + expectMethodToBeCalledOnceWith(dashboardProps.deleteModulePermissions, [ + 'mockDraftId', + 99, + { mode: MODE_ALL } + ]) + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + dashboardProps.mode = MODE_COLLECTION + dashboardProps.collection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' + } + act(() => { + component.update() + }) + dialogComponent.props.loadUsersForModule('mockDraftId') + expectMethodToBeCalledOnceWith(dashboardProps.loadUsersForModule, ['mockDraftId']) + dialogComponent.props.addUserToModule('mockDraftId', 99) + expectMethodToBeCalledOnceWith(dashboardProps.addUserToModule, ['mockDraftId', 99]) + dialogComponent.props.deleteModulePermissions('mockDraftId', 99) + expectMethodToBeCalledOnceWith(dashboardProps.deleteModulePermissions, [ + 'mockDraftId', + 99, + { collectionId: 'mockCollectionId', mode: MODE_COLLECTION } + ]) dialogComponent.props.onClose() expectMethodToBeCalledOnceWith(dashboardProps.closeModal) @@ -665,6 +1599,250 @@ describe('Dashboard', () => { component.unmount() }) + test('renders "Module Collections" dialog and adjusts callbacks for each mode', async () => { + dashboardProps.dialog = 'module-manage-collections' + dashboardProps.loadModuleCollections = jest.fn() + dashboardProps.loadCollectionModules = jest.fn() + dashboardProps.moduleAddToCollection = jest.fn() + dashboardProps.moduleRemoveFromCollection = jest.fn() + dashboardProps.mode = MODE_RECENT + let component + act(() => { + component = create() + }) + + expectDialogToBeRendered(component, ModuleManageCollectionsDialog, 'Module Collections') + const dialogComponent = component.root.findByType(ModuleManageCollectionsDialog) + + dialogComponent.props.loadModuleCollections('mockDraftId') + expectMethodToBeCalledOnceWith(dashboardProps.loadModuleCollections, ['mockDraftId']) + dialogComponent.props.moduleAddToCollection('mockDraftId', 'mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.moduleAddToCollection, [ + 'mockDraftId', + 'mockCollectionId' + ]) + dialogComponent.props.moduleRemoveFromCollection('mockDraftId', 'mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.moduleRemoveFromCollection, [ + 'mockDraftId', + 'mockCollectionId' + ]) + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + + dashboardProps.mode = MODE_ALL + act(() => { + component.update() + }) + + dialogComponent.props.loadModuleCollections('mockDraftId') + expectMethodToBeCalledOnceWith(dashboardProps.loadModuleCollections, ['mockDraftId']) + dialogComponent.props.moduleAddToCollection('mockDraftId', 'mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.moduleAddToCollection, [ + 'mockDraftId', + 'mockCollectionId' + ]) + dialogComponent.props.moduleRemoveFromCollection('mockDraftId', 'mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.moduleRemoveFromCollection, [ + 'mockDraftId', + 'mockCollectionId' + ]) + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + + dashboardProps.mode = MODE_COLLECTION + dashboardProps.collection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' + } + dashboardProps.moduleAddToCollection.mockResolvedValue(null) + dashboardProps.moduleRemoveFromCollection.mockResolvedValue(null) + act(() => { + component.update() + }) + + dialogComponent.props.loadModuleCollections('mockDraftId') + expectMethodToBeCalledOnceWith(dashboardProps.loadModuleCollections, ['mockDraftId']) + // in MODE_COLLECTION, this becomes a promise chain + await dialogComponent.props.moduleAddToCollection('mockDraftId', 'mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.moduleAddToCollection, [ + 'mockDraftId', + 'mockCollectionId' + ]) + expectMethodToBeCalledOnceWith(dashboardProps.loadCollectionModules, [ + 'mockCollectionId', + { collectionId: 'mockCollectionId', mode: MODE_COLLECTION } + ]) + // in MODE_COLLECTION, this becomes a promise chain + await dialogComponent.props.moduleRemoveFromCollection('mockDraftId', 'mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.moduleRemoveFromCollection, [ + 'mockDraftId', + 'mockCollectionId' + ]) + expectMethodToBeCalledOnceWith(dashboardProps.loadCollectionModules, [ + 'mockCollectionId', + { collectionId: 'mockCollectionId', mode: MODE_COLLECTION } + ]) + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + }) + + test('renders collection module management dialog and adjusts callbacks for each mode', () => { + dashboardProps.dialog = 'collection-manage-modules' + dashboardProps.loadCollectionModules = jest.fn() + dashboardProps.collectionAddModule = jest.fn() + dashboardProps.collectionRemoveModule = jest.fn() + dashboardProps.mode = MODE_RECENT + let component + act(() => { + component = create() + }) + + expectDialogToBeRendered(component, CollectionManageModulesDialog, '') + const dialogComponent = component.root.findByType(CollectionManageModulesDialog) + + dialogComponent.props.loadCollectionModules('mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.loadCollectionModules, ['mockCollectionId']) + dialogComponent.props.collectionAddModule('mockDraftId', 'mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.collectionAddModule, [ + 'mockDraftId', + 'mockCollectionId' + ]) + dialogComponent.props.collectionRemoveModule('mockDraftId', 'mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.collectionRemoveModule, [ + 'mockDraftId', + 'mockCollectionId' + ]) + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + + dashboardProps.mode = MODE_ALL + act(() => { + component.update() + }) + + dialogComponent.props.loadCollectionModules('mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.loadCollectionModules, ['mockCollectionId']) + dialogComponent.props.collectionAddModule('mockDraftId', 'mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.collectionAddModule, [ + 'mockDraftId', + 'mockCollectionId' + ]) + dialogComponent.props.collectionRemoveModule('mockDraftId', 'mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.collectionRemoveModule, [ + 'mockDraftId', + 'mockCollectionId' + ]) + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + + dashboardProps.mode = MODE_ALL + act(() => { + component.update() + }) + + dashboardProps.mode = MODE_COLLECTION + dashboardProps.collection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' + } + act(() => { + component.update() + }) + + dialogComponent.props.loadCollectionModules('mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.loadCollectionModules, ['mockCollectionId']) + dialogComponent.props.collectionAddModule('mockDraftId', 'mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.collectionAddModule, [ + 'mockDraftId', + 'mockCollectionId', + { collectionId: 'mockCollectionId', mode: MODE_COLLECTION } + ]) + dialogComponent.props.collectionRemoveModule('mockDraftId', 'mockCollectionId') + expectMethodToBeCalledOnceWith(dashboardProps.collectionRemoveModule, [ + 'mockDraftId', + 'mockCollectionId', + { collectionId: 'mockCollectionId', mode: MODE_COLLECTION } + ]) + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + + dashboardProps.mode = MODE_ALL + act(() => { + component.update() + }) + }) + + test('renders collection multi-module add dialog and runs callbacks properly', () => { + dashboardProps.dialog = 'collection-bulk-add-modules' + + let component + act(() => { + component = create() + }) + + expectDialogToBeRendered(component, CollectionBulkAddModulesDialog, '') + const dialogComponent = component.root.findByType(CollectionBulkAddModulesDialog) + expect(dialogComponent.props.title).toBe('') + + dialogComponent.props.onClose() + expect(dashboardProps.closeModal).toHaveBeenCalledTimes(1) + + component.unmount() + }) + + test('renders "Rename Collection" dialog and adjusts callbacks for each mode', () => { + dashboardProps.dialog = 'collection-rename' + dashboardProps.renameCollection = jest.fn() + dashboardProps.mode = MODE_RECENT + let component + act(() => { + component = create() + }) + + expectDialogToBeRendered(component, CollectionRenameDialog, 'Rename Collection') + const dialogComponent = component.root.findByType(CollectionRenameDialog) + + dialogComponent.props.onAccept('mockCollectionId', 'New Collection Title') + expectMethodToBeCalledOnceWith(dashboardProps.renameCollection, [ + 'mockCollectionId', + 'New Collection Title' + ]) + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + + // dialog shouldn't be available in MODE_ALL, but just to be sure it acts the same + dashboardProps.mode = MODE_ALL + act(() => { + component.update() + }) + + dialogComponent.props.onAccept('mockCollectionId', 'New Collection Title') + expectMethodToBeCalledOnceWith(dashboardProps.renameCollection, [ + 'mockCollectionId', + 'New Collection Title' + ]) + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + + dashboardProps.mode = MODE_COLLECTION + dashboardProps.collection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' + } + act(() => { + component.update() + }) + + dialogComponent.props.onAccept('mockCollectionId', 'New Collection Title') + expectMethodToBeCalledOnceWith(dashboardProps.renameCollection, [ + 'mockCollectionId', + 'New Collection Title', + { collectionId: 'mockCollectionId', mode: MODE_COLLECTION } + ]) + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + }) + test('renders no dialogs if props.dialog value is unsupported', () => { dashboardProps.dialog = 'some-unsupported-value' let component @@ -677,18 +1855,104 @@ describe('Dashboard', () => { component.unmount() }) - test('restoreModules function gets called as expected', async () => { - // Let's first show the deleted modules page so that the Restore All - // button shows up - const props = { - ...dashboardProps, - showDeletedModules: true, - multiSelectMode: true + test('MODE_COLLECTION collection management buttons work correctly', async () => { + dashboardProps.mode = MODE_COLLECTION + dashboardProps.showCollectionManageModules = jest.fn() + dashboardProps.showCollectionRename = jest.fn() + dashboardProps.deleteCollection = jest.fn() + dashboardProps.deleteCollection.mockResolvedValueOnce(null) + dashboardProps.collection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' } - const reusableComponent = let component act(() => { - component = create(reusableComponent) + component = create() + }) + + const expectedControlBarClasses = + 'repository--main-content--control-bar is-not-multi-select-mode' + const controlBar = component.root.findByProps({ className: expectedControlBarClasses }) + + controlBar.findByProps({ className: 'manage-modules' }).props.onClick() + expectMethodToBeCalledOnceWith(dashboardProps.showCollectionManageModules, [ + dashboardProps.collection + ]) + + controlBar.findByProps({ className: 'rename' }).props.onClick() + expectMethodToBeCalledOnceWith(dashboardProps.showCollectionRename, [dashboardProps.collection]) + + // delete button clicked - canceled + window.confirm = jest.fn() + window.confirm.mockReturnValue(false) + controlBar.findByProps({ className: 'dangerous-button' }).props.onClick() + expectMethodToBeCalledOnceWith(window.confirm, [ + 'Delete collection "Mock Collection Title"? Modules in this collection will not be deleted.' + ]) + expect(dashboardProps.deleteCollection).not.toHaveBeenCalled() + + // delete button clicked - confirmed + delete window.location + window.location = { + assign: jest.fn() + } + + window.confirm.mockReset() + window.confirm.mockReturnValue(true) + controlBar.findByProps({ className: 'dangerous-button' }).props.onClick() + expectMethodToBeCalledOnceWith(window.confirm, [ + 'Delete collection "Mock Collection Title"? Modules in this collection will not be deleted.' + ]) + await expectMethodToBeCalledOnceWith(dashboardProps.deleteCollection, [ + dashboardProps.collection + ]) + expect(window.location.assign).toBeCalledWith('/dashboard') + }) + + test('renders with MODE_DELETED correctly, no modules, no multiselect', () => { + dashboardProps.mode = MODE_DELETED + const component = create() + + //numerous changes to check for within the main content area + const mainContent = component.root.findByProps({ className: 'repository--main-content' }) + //some in the control bar + expectDeletedModeControlBar(component) + + const moduleComponents = component.root.findAllByType(Module) + expect(moduleComponents.length).toBe(0) + + // MODE_DELETED will change the 'My Modules' title to 'My Deleted Modules' + expectNormalModulesAreaClassesWithTitle(mainContent, 'My Deleted Modules') + }) + + test('renders with MODE_DELETE correctly, modules', () => { + dashboardProps.mode = MODE_DELETED + dashboardProps.myModules = [...standardMyModules] + + const component = create() + + expectDeletedModeControlBar(component) + + const moduleComponents = component.root.findAllByType(Module) + expect(moduleComponents.length).toBe(standardMyModules.length) + }) + + test('restoreModules function gets called as expected', async () => { + const originalAlert = global.alert + global.alert = jest.fn() + + const mockSelectedModules = [standardMyModules[0].draftId, standardMyModules[1].draftId] + + dashboardProps.mode = MODE_DELETED + dashboardProps.myModules = [...standardMyModules] + dashboardProps.multiSelectMode = true + dashboardProps.selectedModules = mockSelectedModules + + dashboardProps.bulkRestoreModules = jest.fn().mockResolvedValue(true) + + let component + act(() => { + component = create() }) // Actual testing starts here @@ -701,49 +1965,15 @@ describe('Dashboard', () => { restoreAllButton.props.onClick(mockClickEvent) }) expect(dashboardProps.bulkRestoreModules).toHaveBeenCalled() + expect(dashboardProps.bulkRestoreModules).toHaveBeenCalledWith(mockSelectedModules) component.unmount() return dashboardProps.bulkRestoreModules().then(() => { expect(global.alert).toHaveBeenCalledTimes(1) expect(global.alert).toHaveBeenCalledWith('The selected modules were successfully restored.') - }) - }) - - test('dashboard switch tabs as expected', () => { - // Dashboard opens up at 'My Modules' tab - let props = { - ...dashboardProps, - showDeletedModules: false, - multiSelectMode: false - } - let reusableComponent = - let component - act(() => { - component = create(reusableComponent) - }) - // Going to 'My Deleted Modules' page... - let switchTabsButton = component.root.findAllByType(Button)[3] - act(() => { - switchTabsButton.props.onClick() - }) - expect(dashboardProps.getDeletedModules).toHaveBeenCalled() - - // Going to 'My Modules' page... - props = { - ...dashboardProps, - showDeletedModules: true, - multiSelectMode: false - } - reusableComponent = - act(() => { - component = create(reusableComponent) - }) - switchTabsButton = component.root.findAllByType(Button)[0] - act(() => { - switchTabsButton.props.onClick() + global.alert = originalAlert }) - expect(dashboardProps.getModules).toHaveBeenCalled() }) }) diff --git a/packages/app/obojobo-repository/shared/components/layouts/default.scss b/packages/app/obojobo-repository/shared/components/layouts/default.scss index 70d42905bb..2fc18ece17 100644 --- a/packages/app/obojobo-repository/shared/components/layouts/default.scss +++ b/packages/app/obojobo-repository/shared/components/layouts/default.scss @@ -74,6 +74,7 @@ from { opacity: 1; } + to { opacity: 0.5; } @@ -138,6 +139,10 @@ } } + &.stretch-width::before { + right: 0; + } + .repository--main-content--sort { display: flex; align-items: center; diff --git a/packages/app/obojobo-repository/shared/components/module-options-dialog.jsx b/packages/app/obojobo-repository/shared/components/module-options-dialog.jsx index 0af6f2b1bb..9cd0f40dd1 100644 --- a/packages/app/obojobo-repository/shared/components/module-options-dialog.jsx +++ b/packages/app/obojobo-repository/shared/components/module-options-dialog.jsx @@ -72,6 +72,16 @@ const ModuleOptionsDialog = props => (
View and restore previous versions.
+ +
Add to or remove from private collections.
+ ) : ( diff --git a/packages/app/obojobo-repository/shared/components/module.scss b/packages/app/obojobo-repository/shared/components/module.scss index 95f4ce5274..6e2e3df262 100644 --- a/packages/app/obojobo-repository/shared/components/module.scss +++ b/packages/app/obojobo-repository/shared/components/module.scss @@ -10,7 +10,7 @@ font-size: 0.75em; text-align: center; border: 1px solid rgba(0, 0, 0, 0); - animation: slide-up 0.4s ease; + animation: repository-module-slide-up 0.4s ease; cursor: pointer; > button { @@ -79,7 +79,7 @@ } } -@keyframes slide-up { +@keyframes repository-module-slide-up { 0% { opacity: 0; transform: translateY(20px); diff --git a/packages/app/obojobo-repository/shared/components/multi-button.scss b/packages/app/obojobo-repository/shared/components/multi-button.scss index b48948faea..8de36728de 100644 --- a/packages/app/obojobo-repository/shared/components/multi-button.scss +++ b/packages/app/obojobo-repository/shared/components/multi-button.scss @@ -2,6 +2,8 @@ .repository--button.repository--multi-button { $size: 2.3em; + $closed-width: 7.5em; + $open-width: 9.7em; position: relative; border: 1px solid $color-action; @@ -9,7 +11,8 @@ padding-left: 3.5em; padding-top: 1em; padding-bottom: 1em; - margin-right: 0.8em; + width: $closed-width; + margin-right: $open-width - $closed-width; font-size: 0.65em; > button { @@ -79,6 +82,8 @@ color: $color-action; background-color: white; border: 1px solid darken($color-banner-bg, 10%); + margin-right: 0; + width: $open-width; .child-buttons { display: block; 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 f7cba4ff84..fb142afe60 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 @@ -21,10 +21,12 @@ PageDashboardServer.defaultProps = { dialog: null, selectedModule: {}, draftPermissions: {}, + myCollections: [], myModules: [], selectedModules: [], multiSelectMode: false, moduleSearchString: '', + collectionSearchString: '', shareSearchString: '', versionHistory: { hasFetched: false, diff --git a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js index 0aafe86dd2..227b77d518 100644 --- a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js +++ b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js @@ -12,11 +12,29 @@ const { DELETE_MODULE_PERMISSIONS, DELETE_MODULE, BULK_DELETE_MODULES, + BULK_ADD_MODULES_TO_COLLECTIONS, + BULK_REMOVE_MODULES_FROM_COLLECTION, CREATE_NEW_MODULE, FILTER_MODULES, + FILTER_COLLECTIONS, SELECT_MODULES, DESELECT_MODULES, SHOW_MODULE_MORE, + CREATE_NEW_COLLECTION, + SHOW_MODULE_MANAGE_COLLECTIONS, + LOAD_MODULE_COLLECTIONS, + MODULE_ADD_TO_COLLECTION, + MODULE_REMOVE_FROM_COLLECTION, + SHOW_COLLECTION_BULK_ADD_MODULES_DIALOG, + SHOW_COLLECTION_MANAGE_MODULES, + LOAD_COLLECTION_MODULES, + COLLECTION_ADD_MODULE, + COLLECTION_REMOVE_MODULE, + LOAD_MODULE_SEARCH, + CLEAR_MODULE_SEARCH_RESULTS, + SHOW_COLLECTION_RENAME, + RENAME_COLLECTION, + DELETE_COLLECTION, SHOW_VERSION_HISTORY, RESTORE_VERSION, SHOW_ASSESSMENT_SCORE_DATA, @@ -31,6 +49,12 @@ const searchPeopleResultsState = (isFetching = false, hasFetched = false, items isFetching }) +const searchModuleResultsState = (isFetching = false, hasFetched = false, items = []) => ({ + items, + hasFetched, + isFetching +}) + const closedDialogState = () => ({ dialog: null, dialogProps: null, @@ -51,15 +75,61 @@ function filterModules(modules, searchString) { .includes(searchString) ) } +function filterCollections(collections, searchString) { + searchString = ('' + searchString).replace(whitespaceRegex, '').toLowerCase() + + return collections.filter(c => + ((c.title || '') + c.id) + .replace(whitespaceRegex, '') + .toLowerCase() + .includes(searchString) + ) +} function DashboardReducer(state, action) { switch (action.type) { + case CREATE_NEW_COLLECTION: + case DELETE_COLLECTION: + return handle(state, action, { + success: prevState => { + const filteredCollections = filterCollections( + action.payload.value, + state.collectionSearchString + ) + return { + ...prevState, + collectionSearchString: '', + myCollections: action.payload.value, + filteredCollections + } + } + }) + case RENAME_COLLECTION: + return handle(state, action, { + success: prevState => { + const newState = { ...prevState } + newState.myCollections = action.payload.value + newState.filteredCollections = filterCollections( + action.payload.value, + state.collectionSearchString + ) + if ( + action.meta.currentCollectionId && + action.meta.changedCollectionId === action.meta.currentCollectionId + ) { + newState.collection.title = action.meta.changedCollectionTitle + } + return newState + } + }) + case CREATE_NEW_MODULE: return handle(state, action, { // update my modules list & remove filtering because the new module could be filtered success: prevState => ({ ...prevState, - myModules: action.payload.value, + moduleCount: action.payload.value.allCount, + myModules: action.payload.value.modules, moduleSearchString: '', filteredModules: null }) @@ -71,8 +141,16 @@ function DashboardReducer(state, action) { start: () => ({ ...state, ...closedDialogState() }), // update myModules and re-apply the filter if one exists success: prevState => { - const filteredModules = filterModules(action.payload.value, state.moduleSearchString) - return { ...prevState, myModules: action.payload.value, filteredModules } + const filteredModules = filterModules( + action.payload.value.modules, + state.moduleSearchString + ) + return { + ...prevState, + moduleCount: action.payload.value.allCount, + myModules: action.payload.value.modules, + filteredModules + } } }) @@ -80,14 +158,38 @@ function DashboardReducer(state, action) { 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) + const filteredModules = filterModules( + action.payload.value.modules, + state.moduleSearchString + ) return { ...prevState, - myModules: action.payload.value, + myModules: action.payload.value.modules, + moduleCount: action.payload.value.allCount, filteredModules, selectedModules: [], - multiSelectMode: false, - showDeletedModules: false + multiSelectMode: false + } + } + }) + + case BULK_ADD_MODULES_TO_COLLECTIONS: + return { + ...state, + selectedModules: [], + multiSelectMode: false + } + + case BULK_REMOVE_MODULES_FROM_COLLECTION: + return handle(state, action, { + success: prevState => { + return { + ...prevState, + myModules: action.payload.value.modules, + collectionModules: action.payload.value.modules, + moduleCount: action.payload.value.allCount, + selectedModules: [], + multiSelectMode: false } } }) @@ -119,6 +221,12 @@ function DashboardReducer(state, action) { filteredModules: filterModules(state.myModules, action.searchString), moduleSearchString: action.searchString } + case FILTER_COLLECTIONS: + return { + ...state, + filteredCollections: filterCollections(state.myCollections, action.searchString), + collectionSearchString: action.searchString + } case SELECT_MODULES: return { @@ -156,7 +264,21 @@ function DashboardReducer(state, action) { const newState = { ...prevState } newState.draftPermissions = { ...newState.draftPermissions } newState.draftPermissions[newState.selectedModule.draftId] = searchPeople - if (action.payload.modules) newState.myModules = action.payload.modules + if (action.payload.modules) { + newState.moduleCount = action.payload.modules.allCount + newState.myModules = action.payload.modules.modules + } + return newState + } + }) + + case LOAD_MODULE_COLLECTIONS: + case MODULE_ADD_TO_COLLECTION: + case MODULE_REMOVE_FROM_COLLECTION: + return handle(state, action, { + success: prevState => { + const newState = { ...prevState } + newState.draftCollections = action.payload.value return newState } }) @@ -167,6 +289,69 @@ function DashboardReducer(state, action) { success: prevState => ({ ...prevState, searchPeople: { items: action.payload.value } }) }) + case SHOW_MODULE_MANAGE_COLLECTIONS: + return { + ...state, + dialog: 'module-manage-collections', + selectedModule: action.module + } + + case SHOW_COLLECTION_BULK_ADD_MODULES_DIALOG: + return { + ...state, + dialog: 'collection-bulk-add-modules', + selectedModules: action.selectedModules + } + + case SHOW_COLLECTION_MANAGE_MODULES: + return { + ...state, + dialog: 'collection-manage-modules', + selectedCollection: action.collection, + searchModules: searchModuleResultsState() + } + + case LOAD_MODULE_SEARCH: + return handle(state, action, { + start: prevState => ({ + ...prevState, + collectionModuleSearchString: action.meta.searchString + }), + success: prevState => ({ + ...prevState, + searchModules: { + items: action.payload.value.modules + } + }) + }) + + case CLEAR_MODULE_SEARCH_RESULTS: + return { ...state, searchModules: searchModuleResultsState(), shareSearchString: '' } + + case COLLECTION_ADD_MODULE: + case COLLECTION_REMOVE_MODULE: + case LOAD_COLLECTION_MODULES: + return handle(state, action, { + success: prevState => { + const newState = { ...prevState } + newState.collectionModules = action.payload.value.modules + if ( + action.meta.currentCollectionId && + action.meta.changedCollectionId === action.meta.currentCollectionId + ) { + newState.myModules = action.payload.value.modules + } + return newState + } + }) + + case SHOW_COLLECTION_RENAME: + return { + ...state, + dialog: 'collection-rename', + selectedCollection: action.collection + } + case SHOW_VERSION_HISTORY: return handle(state, action, { start: prevState => ({ @@ -235,28 +420,24 @@ function DashboardReducer(state, action) { return handle(state, action, { success: prevState => ({ ...prevState, - myModules: action.payload.value, - showDeletedModules: false + myModules: action.payload.value }) }) case GET_DELETED_MODULES: return handle(state, action, { success: prevState => ({ - selectedModules: prevState.selectedModules, - currentUser: prevState.currentUser, - myModules: action.payload.value, - showDeletedModules: true + ...prevState, + myModules: action.payload.value }) }) case BULK_RESTORE_MODULES: return handle(state, action, { success: prevState => ({ + ...prevState, selectedModules: [], - currentUser: prevState.currentUser, myModules: action.payload.value, - showDeletedModules: false, multiSelectMode: false }) }) 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 17b12479cb..2da368adaf 100644 --- a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.test.js +++ b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.test.js @@ -17,11 +17,29 @@ const { DELETE_MODULE_PERMISSIONS, DELETE_MODULE, BULK_DELETE_MODULES, + BULK_ADD_MODULES_TO_COLLECTIONS, + BULK_REMOVE_MODULES_FROM_COLLECTION, CREATE_NEW_MODULE, FILTER_MODULES, + FILTER_COLLECTIONS, SELECT_MODULES, DESELECT_MODULES, SHOW_MODULE_MORE, + CREATE_NEW_COLLECTION, + SHOW_MODULE_MANAGE_COLLECTIONS, + LOAD_MODULE_COLLECTIONS, + MODULE_ADD_TO_COLLECTION, + MODULE_REMOVE_FROM_COLLECTION, + SHOW_COLLECTION_BULK_ADD_MODULES_DIALOG, + SHOW_COLLECTION_MANAGE_MODULES, + LOAD_COLLECTION_MODULES, + COLLECTION_ADD_MODULE, + COLLECTION_REMOVE_MODULE, + LOAD_MODULE_SEARCH, + CLEAR_MODULE_SEARCH_RESULTS, + SHOW_COLLECTION_RENAME, + RENAME_COLLECTION, + DELETE_COLLECTION, SHOW_VERSION_HISTORY, SHOW_ASSESSMENT_SCORE_DATA, RESTORE_VERSION, @@ -50,6 +68,119 @@ describe('Dashboard Reducer', () => { Pack.handle.mockClear() }) + const runCreateOrDeleteCollectionActionTest = (testAction, testFilter = false) => { + const mockCollectionList = [ + { + id: 'mockCollectionId', + title: '' // filtering logic has a branch for empty titles that needs covering + }, + { + id: 'mockCollectionId2', + title: 'B Mock Collection' + } + ] + + const initialState = { + collectionSearchString: testFilter ? 'B' : '', + myCollections: [ + { + id: 'oldMockCollectionId', + title: 'Old Mock Collection' + } + ], + filteredCollections: [ + { + id: 'oldMockCollectionId', + title: 'Old Mock Collection' + } + ] + } + const action = { + type: testAction, + // this action occurs after a new collection is created and the current user's + // collections are queried - so it will contain a list of collections + payload: { + value: mockCollectionList + } + } + // asynchronous action - state changes on success + const handler = dashboardReducer(initialState, action) + + const newState = handleSuccess(handler) + expect(newState.myCollections).not.toEqual(initialState.myCollections) + expect(newState.myCollections).toEqual(mockCollectionList) + + // empty collectionSearchString = no filtering + if (testFilter) { + expect(newState.filteredCollections).not.toEqual(initialState.filteredCollections) + expect(newState.filteredCollections).toEqual([{ ...mockCollectionList[1] }]) + } else { + expect(newState.filteredCollections).not.toEqual(initialState.filteredCollections) + expect(newState.filteredCollections).toEqual(mockCollectionList) + } + } + + const runRenameCollectionActionTest = (testFilter = false, testSameCollection = false) => { + const mockCollectionList = [ + { + id: 'mockCollectionId', + title: 'A Mock Collection' + }, + { + id: 'mockCollectionId2', + title: 'B Mock Collection' + } + ] + + const initialState = { + collectionSearchString: testFilter ? 'B' : '', + myCollections: [ + { + id: 'oldMockCollectionId', + title: 'Old Mock Collection' + } + ], + filteredCollections: [ + { + id: 'oldMockCollectionId', + title: 'Old Mock Collection' + } + ], + collection: { + id: 'collectionId', + title: 'Collection Title' + } + } + const action = { + type: RENAME_COLLECTION, + meta: { + changedCollectionTitle: 'New Collection Title', + changedCollectionId: 'collectionId', + // eslint-disable-next-line no-undefined + currentCollectionId: testSameCollection ? 'collectionId' : undefined + }, + payload: { + value: mockCollectionList + } + } + const handler = dashboardReducer(initialState, action) + // RENAME_COLLECTION is an asynchronous action - state changes on success + const newState = handleSuccess(handler) + expect(newState.myCollections).not.toEqual(initialState.myCollections) + expect(newState.myCollections).toEqual(mockCollectionList) + if (testFilter) { + expect(newState.filteredCollections).not.toEqual(initialState.filteredCollections) + expect(newState.filteredCollections).toEqual([{ ...mockCollectionList[1] }]) + } else { + expect(newState.filteredCollections).not.toEqual(initialState.filteredCollections) + expect(newState.filteredCollections).toEqual(mockCollectionList) + } + const expectedCollectionTitle = testSameCollection + ? 'New Collection Title' + : initialState.collection.title + expect(newState.collection.title).toEqual(expectedCollectionTitle) + } + const runCreateOrDeleteModuleActionTest = testAction => { const isDeleteModuleTest = testAction === DELETE_MODULE const mockModuleList = [ @@ -84,7 +215,10 @@ describe('Dashboard Reducer', () => { // this action occurs after the current user's modules are // queried - so it will contain a list of modules payload: { - value: mockModuleList + value: { + allCount: mockModuleList.length, + modules: mockModuleList + } } } @@ -146,6 +280,7 @@ describe('Dashboard Reducer', () => { draftPermissions: { mockDraftId: mockInitialDraftPermissions }, + moduleCount: mockModuleList.length + 1, myModules: [ ...mockModuleList, { @@ -155,7 +290,10 @@ describe('Dashboard Reducer', () => { ] } - const modulePayload = mockModuleList + const modulePayload = { + allCount: mockModuleList.length, + modules: mockModuleList + } const action = { type: testAction, @@ -185,11 +323,118 @@ describe('Dashboard Reducer', () => { items: mockUserList }) if (testModules) { + expect(newState.moduleCount).not.toEqual(initialState.moduleCount) + expect(newState.myModules).not.toEqual(initialState.myModules) + expect(newState.moduleCount).toEqual(mockModuleList.length) + expect(newState.myModules).toEqual(mockModuleList) + } + } + + const runModuleCollectionActions = testAction => { + const mockCollectionList = [ + { + id: 'mockCollectionId', + title: 'Mock Collection Title' + }, + { + id: 'mockCollectionId2', + title: 'Mock Collection Title 2' + } + ] + + const initialState = { + draftCollections: [{ ...mockCollectionList[0] }] + } + + const action = { + type: testAction, + payload: { + value: mockCollectionList + } + } + + // asynchronous action - state changes on success + const handler = dashboardReducer(initialState, action) + + const newState = handleSuccess(handler) + expect(newState.draftCollections).not.toEqual(initialState.draftCollections) + expect(newState.draftCollections).toEqual(mockCollectionList) + } + + const runCollectionModuleActions = (testAction, testCurrentCollection = false) => { + const mockModuleList = [ + { + draftId: 'mockModuleId', + title: 'Mock Collection Title' + }, + { + draftId: 'mockModuleId2', + title: 'Mock Collection Title 2' + } + ] + + const initialState = { + collectionModules: [{ ...mockModuleList[0] }], + // eslint-disable-next-line no-undefined + myModules: testCurrentCollection ? [{ ...mockModuleList[0] }] : undefined + } + + const action = { + type: testAction, + meta: { + changedCollectionId: 'mockCollectionId', + // eslint-disable-next-line no-undefined + currentCollectionId: testCurrentCollection ? 'mockCollectionId' : undefined + }, + payload: { + value: { + allCount: mockModuleList.length, + modules: mockModuleList + } + } + } + + // asynchronous action - state changes on success + const handler = dashboardReducer(initialState, action) + + const newState = handleSuccess(handler) + expect(newState.collectionModules).not.toEqual(initialState.collectionModules) + expect(newState.collectionModules).toEqual(mockModuleList) + if (testCurrentCollection) { expect(newState.myModules).not.toEqual(initialState.myModules) expect(newState.myModules).toEqual(mockModuleList) } } + test('CREATE_NEW_COLLECTION action modifies state correctly - no filter', () => { + runCreateOrDeleteCollectionActionTest(CREATE_NEW_COLLECTION) + }) + test('CREATE_NEW_COLLECTION action modifies state correctly - filter', () => { + runCreateOrDeleteCollectionActionTest(CREATE_NEW_COLLECTION, true) + }) + //DELETE_COLLECTION and CREATE_NEW_COLLECTION should have identical results based on inputs + test('DELETE_COLLECTION action modifies state correctly - no filter', () => { + runCreateOrDeleteCollectionActionTest(DELETE_COLLECTION) + }) + test('DELETE_COLLECTION action modifies state correctly - filter', () => { + runCreateOrDeleteCollectionActionTest(DELETE_COLLECTION, true) + }) + + // more or less the same as CREATE_NEW_COLLECTION, but will make additional state + // adjustment if currentCollectionId === changedCollectionId + test('RENAME_COLLECTION action modifies state correctly - no filter, not same collection', () => { + runRenameCollectionActionTest() + }) + test('RENAME_COLLECTION action modifies state correctly - no filter, same collection', () => { + runRenameCollectionActionTest(false, true) + }) + test('RENAME_COLLECTION action modifies state correctly - filter, not same collection', () => { + runRenameCollectionActionTest(true) + }) + test('RENAME_COLLECTION action modifies state correctly - filter, same collection', () => { + runRenameCollectionActionTest(true, true) + }) + test('CREATE_NEW_MODULE action modifies state correctly', () => { runCreateOrDeleteModuleActionTest(CREATE_NEW_MODULE) }) @@ -232,7 +477,10 @@ describe('Dashboard Reducer', () => { const action = { type: BULK_DELETE_MODULES, payload: { - value: mockModuleList + value: { + modules: mockModuleList, + allCount: mockModuleList.length + } } } @@ -246,6 +494,74 @@ describe('Dashboard Reducer', () => { expect(newState.multiSelectMode).toBe(false) }) + test('BULK_ADD_MODULES_TO_COLLECTIONS action modifies state correctly', () => { + const initialState = { + multiSelectMode: true, + selectedModules: ['mockDraftId1', 'mockDraftId2'] + } + + const action = { + type: BULK_ADD_MODULES_TO_COLLECTIONS + } + + // BULK_ADD_MODULES_TO_COLLECTIONS is a synchronous action - state changes immediately + const newState = dashboardReducer(initialState, action) + expect(newState.selectedModules).toEqual([]) + expect(newState.multiSelectMode).toBe(false) + }) + + test('BULK_REMOVE_MODULES_FROM_COLLECTION action modifies state correctly', () => { + const mockModuleList = [ + { + draftId: 'mockDraftId2', + title: 'Mock Module 2' + } + ] + + const oldModules = [ + { + draftId: 'mockDraftId1', + title: 'Mock Module 1' + }, + { + draftId: 'mockDraftId2', + title: 'Mock Module 2' + }, + { + draftId: 'mockDraftId3', + title: 'Mock Module 3' + } + ] + + const initialState = { + multiSelectMode: true, + selectedModules: ['mockDraftId1', 'mockDraftId3'], + myModules: [...oldModules], + moduleCount: oldModules.length, + collectionModules: [...oldModules] + } + + const action = { + type: BULK_REMOVE_MODULES_FROM_COLLECTION, + payload: { + value: { + modules: mockModuleList, + allCount: mockModuleList.length + } + } + } + + const handler = dashboardReducer(initialState, action) + + const newState = handleSuccess(handler) + expect(newState.myModules).not.toEqual(initialState.myModules) + expect(newState.myModules).toEqual(mockModuleList) + expect(newState.moduleCount).toEqual(mockModuleList.length) + expect(newState.collectionModules).toEqual(mockModuleList) + expect(newState.selectedModules).toEqual([]) + expect(newState.multiSelectMode).toBe(false) + }) + test('SHOW_MODULE_MORE action modifies state correctly', () => { const initialState = { dialog: null, @@ -343,6 +659,38 @@ describe('Dashboard Reducer', () => { expect(newState.moduleSearchString).toBe('B') }) + test('FILTER_COLLECTIONS action modifies state correctly', () => { + const initialState = { + collectionSearchString: 'oldSearchString', + myCollections: [ + { + id: 'mockCollectionId', + title: 'A Mock Collection' + }, + { + id: 'mockCollectionId2', + title: 'B Mock Collection' + } + ], + filteredCollections: [ + { + id: 'oldMockCollectionId', + title: 'Old Mock Collection' + } + ] + } + const action = { + type: FILTER_COLLECTIONS, + searchString: 'B' + } + + // FILTER_COLLECTIONS is a synchronous action - state changes immediately + const newState = dashboardReducer(initialState, action) + expect(newState.myCollections).toEqual(initialState.myCollections) + expect(newState.filteredCollections).toEqual([{ ...initialState.myCollections[1] }]) + expect(newState.collectionSearchString).toBe('B') + }) + test('SELECT_MODULES action modifies state correctly', () => { const initialState = { multiSelectMode: false, @@ -431,6 +779,16 @@ describe('Dashboard Reducer', () => { runModuleUserActionTest(ADD_USER_TO_MODULE) }) + test('LOAD_MODULE_COLLECTIONS action modifies state correctly', () => { + runModuleCollectionActions(LOAD_MODULE_COLLECTIONS) + }) + test('MODULE_ADD_TO_COLLECTION action modifies state correctly', () => { + runModuleCollectionActions(MODULE_ADD_TO_COLLECTION) + }) + test('MODULE_REMOVE_FROM_COLLECTION action modifies state correctly', () => { + runModuleCollectionActions(MODULE_REMOVE_FROM_COLLECTION) + }) + test('LOAD_USER_SEARCH action modifies state correctly', () => { const initialState = { shareSearchString: 'oldSearchString', @@ -472,6 +830,172 @@ describe('Dashboard Reducer', () => { }) }) + test('SHOW_MODULE_MANAGE_COLLECTIONS action modifies state correctly', () => { + const initialState = { + dialog: null, + selectedModule: { + draftId: 'someMockDraftId', + title: 'Some Mock Module Title' + } + } + const mockSelectedModule = { + draftId: 'otherMockDraftId', + title: 'Some Other Mock Module Title' + } + const action = { + type: SHOW_MODULE_MANAGE_COLLECTIONS, + module: mockSelectedModule + } + + // SHOW_MODULE_MANAGE_COLLECTIONS is a synchronous action - state changes immediately + const newState = dashboardReducer(initialState, action) + expect(newState.dialog).toBe('module-manage-collections') + expect(newState.selectedModule).not.toEqual(initialState.selectedModule) + expect(newState.selectedModule).toEqual(mockSelectedModule) + }) + + test('SHOW_COLLECTION_BULK_ADD_MODULES_DIALOG action modifies state correctly', () => { + const initialState = { + dialog: null, + selectedModules: [] + } + + const mockSelectedModules = ['mockDraftId1', 'mockDraftId2'] + + const action = { + type: SHOW_COLLECTION_BULK_ADD_MODULES_DIALOG, + selectedModules: mockSelectedModules + } + + // SHOW_COLLECTION_BULK_ADD_MODULES_DIALOG is a synchronous action - state changes immediately + const newState = dashboardReducer(initialState, action) + expect(newState.dialog).toBe('collection-bulk-add-modules') + expect(newState.selectedModules).toEqual(mockSelectedModules) + }) + + test('SHOW_COLLECTION_MANAGE_MODULES action modifies state correctly', () => { + const initialState = { + dialog: null, + selectedCollection: { + id: 'someMockCollectionId', + title: 'Some Mock Collection Title' + }, + searchModules: null + } + const mockSelectedCollection = { + id: 'otherMockCollectionId', + title: 'Some Other Mock Collection Title' + } + const action = { + type: SHOW_COLLECTION_MANAGE_MODULES, + collection: mockSelectedCollection + } + + // SHOW_COLLECTION_MANAGE_MODULES is a synchronous action - state changes immediately + const newState = dashboardReducer(initialState, action) + expect(newState.dialog).toBe('collection-manage-modules') + expect(newState.selectedCollection).not.toEqual(initialState.selectedCollection) + expect(newState.selectedCollection).toEqual(mockSelectedCollection) + expect(newState.searchModules).toEqual({ ...defaultSearchResultsState }) + }) + + test('LOAD_MODULE_SEARCH action modifies state correctly', () => { + const initialState = { + collectionModuleSearchString: 'oldSearchString', + searchModules: { ...defaultSearchResultsState } + } + + const mockModuleList = [ + { + draftId: 'mockDraftId', + title: 'Mock Draft Title' + }, + { + draftId: 'mockDraftId2', + title: 'Mock Draft Title 2' + } + ] + const action = { + type: LOAD_MODULE_SEARCH, + meta: { + searchString: 'newSearchString' + }, + payload: { + value: { + modules: mockModuleList + } + } + } + + // asynchronous action - state changes on success + const handler = dashboardReducer(initialState, action) + let newState + + newState = handleStart(handler) + expect(newState.collectionModuleSearchString).toEqual('newSearchString') + expect(newState.searchModules).toEqual(initialState.searchModules) + + newState = handleSuccess(handler) + expect(newState.searchModules).not.toEqual(initialState.searchModules) + expect(newState.searchModules).toEqual({ + items: mockModuleList + }) + }) + + test('CLEAR_MODULE_SEARCH_RESULTS action modifies state correctly', () => { + const initialState = { + shareSearchString: 'oldSearchString', + searchModules: null + } + + const action = { type: CLEAR_MODULE_SEARCH_RESULTS } + + // CLEAR_MODULE_SEARCH_RESULTS is a synchronous action - state changes immediately + const newState = dashboardReducer(initialState, action) + expect(newState.searchModules).toEqual(defaultSearchResultsState) + expect(newState.shareSearchString).toBe('') + }) + + test('COLLECTION_ADD_MODULE action modifies state correctly - not same collection', () => { + runCollectionModuleActions(COLLECTION_ADD_MODULE) + }) + test('COLLECTION_ADD_MODULE action modifies state correctly - same collection', () => { + runCollectionModuleActions(COLLECTION_ADD_MODULE, true) + }) + test('COLLECTION_REMOVE_MODULE action modifies state correctly - not same collection', () => { + runCollectionModuleActions(COLLECTION_REMOVE_MODULE) + }) + test('COLLECTION_REMOVE_MODULE action modifies state correctly - same collection', () => { + runCollectionModuleActions(COLLECTION_REMOVE_MODULE, true) + }) + test('LOAD_COLLECTION_MODULES action modifies state correctly - not same collection', () => { + runCollectionModuleActions(LOAD_COLLECTION_MODULES) + }) + test('LOAD_COLLECTION_MODULES action modifies state correctly - same collection', () => { + runCollectionModuleActions(LOAD_COLLECTION_MODULES, true) + }) + + test('SHOW_COLLECTION_RENAME action modifies state correctly', () => { + const initialState = { + dialog: null, + selectedCollection: null + } + const mockSelectedCollection = { + id: 'mockCollectionId', + title: 'Mock Collection Title' + } + const action = { + type: SHOW_COLLECTION_RENAME, + collection: mockSelectedCollection + } + + // SHOW_COLLECTION_RENAME is a synchronous action - state changes immediately + const newState = dashboardReducer(initialState, action) + expect(newState.dialog).toBe('collection-rename') + expect(newState.selectedCollection).not.toEqual(initialState.selectedCollection) + expect(newState.selectedCollection).toEqual(mockSelectedCollection) + }) + test('SHOW_VERSION_HISTORY action modifies state correctly', () => { const initialState = { dialog: null, @@ -681,8 +1205,7 @@ describe('Dashboard Reducer', () => { title: 'C Mock Module' } ], - selectedModules: [], - showDeletedModules: true + selectedModules: [] } const undeletedModules = [ @@ -699,15 +1222,13 @@ describe('Dashboard Reducer', () => { const action = { type: GET_MODULES, payload: { - value: undeletedModules, - showDeletedModules: false + value: undeletedModules } } const handler = dashboardReducer(initialState, action) const newState = handleSuccess(handler) expect(newState.myModules).toEqual(undeletedModules) - expect(newState.showDeletedModules).toBe(false) }) test('GET_DELETED_MODULES action modifies state correctly', () => { @@ -727,8 +1248,7 @@ describe('Dashboard Reducer', () => { title: 'C Mock Module' } ], - selectedModules: [], - showDeletedModules: true + selectedModules: [] } const deletedModules = [ @@ -745,15 +1265,13 @@ describe('Dashboard Reducer', () => { const action = { type: GET_DELETED_MODULES, payload: { - value: deletedModules, - showDeletedModules: true + value: deletedModules } } const handler = dashboardReducer(initialState, action) const newState = handleSuccess(handler) expect(newState.myModules).toEqual(deletedModules) - expect(newState.showDeletedModules).toBe(true) }) test('BULK_RESTORE_MODULES action modifies state correctly', () => { @@ -774,7 +1292,6 @@ describe('Dashboard Reducer', () => { const initialState = { multiSelectMode: true, - showDeletedModules: true, selectedModules: ['mockDraftId'], myModules: [ { @@ -798,7 +1315,6 @@ describe('Dashboard Reducer', () => { expect(newState.myModules).toEqual(mockModuleList) expect(newState.selectedModules).toEqual([]) expect(newState.multiSelectMode).toBe(false) - expect(newState.showDeletedModules).toBe(false) }) test('unrecognized action types just return the current state', () => { diff --git a/packages/app/obojobo-repository/shared/util/get-assessment-stats-from-attempt-stats.test.js b/packages/app/obojobo-repository/shared/util/get-assessment-stats-from-attempt-stats.test.js index b903351102..8e11b8bb3e 100644 --- a/packages/app/obojobo-repository/shared/util/get-assessment-stats-from-attempt-stats.test.js +++ b/packages/app/obojobo-repository/shared/util/get-assessment-stats-from-attempt-stats.test.js @@ -199,6 +199,16 @@ describe('getAssessmentStatsFromAttemptStats', () => { userId: 'User-Beta', assessmentScore: null, completedAt: 'mock-date' + }, + // Draft-C Version-1 Assessment-1 Resource-X User-Alpha: + { + draftId: 'Draft-C', + draftContentId: 'Version-1', + resourceLinkId: 'Resource-X', + assessmentId: 'Assessment-1', + userId: 'User-Alpha', + assessmentScore: null, + completedAt: null } ]) ).toEqual([ @@ -249,6 +259,15 @@ describe('getAssessmentStatsFromAttemptStats', () => { assessmentId: 'Assessment-1', userId: 'User-Beta', highestAssessmentScore: null + }), + expect.objectContaining({ + draftId: 'Draft-C', + draftContentId: 'Version-1', + resourceLinkId: 'Resource-X', + assessmentId: 'Assessment-1', + userId: 'User-Alpha', + highestAssessmentScore: null, + completedAt: null }) ]) }) diff --git a/packages/app/obojobo-repository/shared/util/parse-attempt-report.test.js b/packages/app/obojobo-repository/shared/util/parse-attempt-report.test.js index 5edcd56f71..ac5ce08dcc 100644 --- a/packages/app/obojobo-repository/shared/util/parse-attempt-report.test.js +++ b/packages/app/obojobo-repository/shared/util/parse-attempt-report.test.js @@ -1,6 +1,6 @@ const parseAttemptReport = require('./parse-attempt-report') -describe('parseAtttemptReport', () => { +describe('parseAttemptReport', () => { test('parseAttemptReport returns modified attempt objects', () => { expect(parseAttemptReport([])).toEqual([]) expect( diff --git a/packages/obonode/obojobo-chunks-abstract-assessment/Feedback/__snapshots__/editor-registration.test.js.snap b/packages/obonode/obojobo-chunks-abstract-assessment/Feedback/__snapshots__/editor-registration.test.js.snap index 0ce63b543d..25a879ae8b 100644 --- a/packages/obonode/obojobo-chunks-abstract-assessment/Feedback/__snapshots__/editor-registration.test.js.snap +++ b/packages/obonode/obojobo-chunks-abstract-assessment/Feedback/__snapshots__/editor-registration.test.js.snap @@ -17,4 +17,4 @@ exports[`Feedback editor plugins.renderNode renders a node 1`] = ` } } /> -`; +`; \ No newline at end of file diff --git a/packages/obonode/obojobo-chunks-abstract-assessment/package.json b/packages/obonode/obojobo-chunks-abstract-assessment/package.json index 49a951c430..5620e53ef0 100644 --- a/packages/obonode/obojobo-chunks-abstract-assessment/package.json +++ b/packages/obonode/obojobo-chunks-abstract-assessment/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-abstract-assessment", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "AbstractAssessment chunk for Obojobo", "scripts": { @@ -28,8 +28,8 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-chunks-question": "^14.0.0", - "obojobo-lib-utils": "^14.0.0" + "obojobo-chunks-question": "^15.0.0", + "obojobo-lib-utils": "^15.0.0" }, "devDependencies": { "lint-staged": "^10.2.2" diff --git a/packages/obonode/obojobo-chunks-action-button/action-button-editor-action.js b/packages/obonode/obojobo-chunks-action-button/action-button-editor-action.js index 4232cf3c9f..08991a43f0 100644 --- a/packages/obonode/obojobo-chunks-action-button/action-button-editor-action.js +++ b/packages/obonode/obojobo-chunks-action-button/action-button-editor-action.js @@ -10,6 +10,10 @@ const ActionButtonEditorAction = props => { } else { description = 'Go to ""' } + // eslint-disable-next-line no-undefined + if (props.value.id && props.value.ignoreLock === undefined ? true : props.value.ignoreLock) { + description = `${description} (Ignore Navigation Lock)` + } break case 'nav:prev': diff --git a/packages/obonode/obojobo-chunks-action-button/editor-component.test.js b/packages/obonode/obojobo-chunks-action-button/editor-component.test.js index 78435a56c1..9ca94f3b1a 100644 --- a/packages/obonode/obojobo-chunks-action-button/editor-component.test.js +++ b/packages/obonode/obojobo-chunks-action-button/editor-component.test.js @@ -178,7 +178,9 @@ test('Action Button Editor displays id for nav:goto trigger', () => { const value = { id: 'mockId' } const component = mount() - expect(component.find('span').html()).toEqual('Go to mockId') + expect(component.find('span').html()).toEqual( + 'Go to mockId (Ignore Navigation Lock)' + ) }) test('Action Button Editor displays empty id for nav:goto trigger', () => { diff --git a/packages/obonode/obojobo-chunks-action-button/package.json b/packages/obonode/obojobo-chunks-action-button/package.json index edf30ac5dc..5ef5aa0335 100644 --- a/packages/obonode/obojobo-chunks-action-button/package.json +++ b/packages/obonode/obojobo-chunks-action-button/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-action-button", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "scripts": { "test": "TZ='America/New_York' jest --verbose", @@ -20,7 +20,7 @@ ] }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-break/package.json b/packages/obonode/obojobo-chunks-break/package.json index 1bd2b894d0..3de7d040e5 100644 --- a/packages/obonode/obojobo-chunks-break/package.json +++ b/packages/obonode/obojobo-chunks-break/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-break", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Break content chunk for Obojobo", "scripts": { @@ -21,7 +21,7 @@ ] }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-code/package.json b/packages/obonode/obojobo-chunks-code/package.json index 390ab62883..a11751220e 100644 --- a/packages/obonode/obojobo-chunks-code/package.json +++ b/packages/obonode/obojobo-chunks-code/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-code", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Code content chunk for Obojobo", "scripts": { @@ -27,7 +27,7 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-figure/__snapshots__/choose-image-modal.test.js.snap b/packages/obonode/obojobo-chunks-figure/__snapshots__/choose-image-modal.test.js.snap index a16a3eb6a1..de9f837e2e 100644 --- a/packages/obonode/obojobo-chunks-figure/__snapshots__/choose-image-modal.test.js.snap +++ b/packages/obonode/obojobo-chunks-figure/__snapshots__/choose-image-modal.test.js.snap @@ -1,25 +1,25 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Choose Image Modal ChooseImageModal component 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select
"`; +exports[`Choose Image Modal ChooseImageModal component 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select
"`; -exports[`Choose Image Modal ChooseImageModal component displays error message 1`] = `"

Upload or Choose an Image

Error: File too large

or

Your Recently Uploaded Images
\\"Select
"`; +exports[`Choose Image Modal ChooseImageModal component displays error message 1`] = `"

Upload or Choose an Image

Error: File too large

or

Your Recently Uploaded Images
\\"Select
"`; -exports[`Choose Image Modal ChooseImageModal component focuses on first element 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select
"`; +exports[`Choose Image Modal ChooseImageModal component focuses on first element 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select
"`; -exports[`Choose Image Modal ImageProperties click on \`Cancel\` 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select
"`; +exports[`Choose Image Modal ImageProperties click on \`Cancel\` 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select
"`; -exports[`Choose Image Modal ImageProperties click on \`OK\` 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select
"`; +exports[`Choose Image Modal ImageProperties click on \`OK\` 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select
"`; -exports[`Choose Image Modal ImageProperties click on \`View More...\` 2`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select\\"Select
"`; +exports[`Choose Image Modal ImageProperties click on \`View More...\` 2`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select\\"Select
"`; -exports[`Choose Image Modal ImageProperties click on an image 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select\\"Select
"`; +exports[`Choose Image Modal ImageProperties click on an image 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select\\"Select
"`; -exports[`Choose Image Modal ImageProperties component changes file 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select
"`; +exports[`Choose Image Modal ImageProperties component changes file 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select
"`; -exports[`Choose Image Modal ImageProperties handle fetching error 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
"`; +exports[`Choose Image Modal ImageProperties handle fetching error 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
"`; -exports[`Choose Image Modal ImageProperties handle promise rejection 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
"`; +exports[`Choose Image Modal ImageProperties handle promise rejection 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
"`; -exports[`Choose Image Modal ImageProperties onKeyPress \`Enter\` on an image 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select\\"Select
"`; +exports[`Choose Image Modal ImageProperties onKeyPress \`Enter\` on an image 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select\\"Select
"`; -exports[`Choose Image Modal ImageProperties onPress that are not \`Enter\` on an image 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select\\"Select
"`; +exports[`Choose Image Modal ImageProperties onPress that are not \`Enter\` on an image 1`] = `"

Upload or Choose an Image

or

Your Recently Uploaded Images
\\"Select\\"Select
"`; diff --git a/packages/obonode/obojobo-chunks-figure/__snapshots__/editor-registration.test.js.snap b/packages/obonode/obojobo-chunks-figure/__snapshots__/editor-registration.test.js.snap index 9031f88dc3..f68098f70f 100644 --- a/packages/obonode/obojobo-chunks-figure/__snapshots__/editor-registration.test.js.snap +++ b/packages/obonode/obojobo-chunks-figure/__snapshots__/editor-registration.test.js.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Figure editor plugins.decorate exits when not relevent 1`] = `Array []`; +exports[`Figure editor plugins.decorate exits when not relevant 1`] = `Array []`; -exports[`Figure editor plugins.decorate exits when not relevent 2`] = `Array []`; +exports[`Figure editor plugins.decorate exits when not relevant 2`] = `Array []`; exports[`Figure editor plugins.decorate renders a placeholder 1`] = ` Array [ diff --git a/packages/obonode/obojobo-chunks-figure/__snapshots__/image-properties-modal.test.js.snap b/packages/obonode/obojobo-chunks-figure/__snapshots__/image-properties-modal.test.js.snap index 06f5ee2f16..6b0ffdd658 100644 --- a/packages/obonode/obojobo-chunks-figure/__snapshots__/image-properties-modal.test.js.snap +++ b/packages/obonode/obojobo-chunks-figure/__snapshots__/image-properties-modal.test.js.snap @@ -2,22 +2,22 @@ exports[`Image Properties Modal ImageProperties Component handles content.url as expected 1`] = `"
MockChooseImageModal
"`; -exports[`Image Properties Modal ImageProperties component 1`] = `"

Image Properties

MockImage
"`; +exports[`Image Properties Modal ImageProperties component 1`] = `"

Image Properties

MockImage
"`; -exports[`Image Properties Modal ImageProperties component calls onConfirm when click "OK" 1`] = `"

Image Properties

MockImage
"`; +exports[`Image Properties Modal ImageProperties component calls onConfirm when click "OK" 1`] = `"

Image Properties

MockImage
"`; exports[`Image Properties Modal ImageProperties component does not focus if ChooseImageModal is opened 1`] = `"
MockChooseImageModal
"`; -exports[`Image Properties Modal ImageProperties component focuses on first element 1`] = `"

Image Properties

MockImage
"`; +exports[`Image Properties Modal ImageProperties component focuses on first element 1`] = `"

Image Properties

MockImage
"`; -exports[`Image Properties Modal ImageProperties component opens ChooseImageModal component and clicks \`OK\` 1`] = `"

Image Properties

MockImage
"`; +exports[`Image Properties Modal ImageProperties component opens ChooseImageModal component and clicks \`OK\` 1`] = `"

Image Properties

MockImage
"`; exports[`Image Properties Modal ImageProperties component renders when click \`Change Image...\` 1`] = `"
MockChooseImageModal
"`; -exports[`Image Properties Modal ImageProperties component with a url opens ChooseImageModal component and clicks \`Cancel\` 1`] = `"

Image Properties

MockImage
"`; +exports[`Image Properties Modal ImageProperties component with a url opens ChooseImageModal component and clicks \`Cancel\` 1`] = `"

Image Properties

MockImage
"`; -exports[`Image Properties Modal ImageProperties component with allowedUploadTypes 1`] = `"

Image Properties

MockImage
px × px
"`; +exports[`Image Properties Modal ImageProperties component with allowedUploadTypes 1`] = `"

Image Properties

MockImage
px × px
"`; -exports[`Image Properties Modal ImageProperties component with custom size 1`] = `"

Image Properties

MockImage
px × px
"`; +exports[`Image Properties Modal ImageProperties component with custom size 1`] = `"

Image Properties

MockImage
px × px
"`; -exports[`Image Properties Modal ImageProperties component without url opens ChooseImageModal component and clicks \`Cancel\` 1`] = `"

Image Properties

MockImage
"`; +exports[`Image Properties Modal ImageProperties component without url opens ChooseImageModal component and clicks \`Cancel\` 1`] = `"

Image Properties

MockImage
"`; diff --git a/packages/obonode/obojobo-chunks-figure/adapter.js b/packages/obonode/obojobo-chunks-figure/adapter.js index 8e56a9de96..65358803db 100644 --- a/packages/obonode/obojobo-chunks-figure/adapter.js +++ b/packages/obonode/obojobo-chunks-figure/adapter.js @@ -23,13 +23,14 @@ const Adapter = { model.setStateProp('width', null, p => parseInt(p, 10)) model.setStateProp('height', null, p => parseInt(p, 10)) model.setStateProp('alt', null) - model.setStateProp('captionWidth', ImageCaptionWidthTypes.IMAGE_WIDTH, p => p.toLowerCase(), [ - ImageCaptionWidthTypes.IMAGE_WIDTH, - ImageCaptionWidthTypes.TEXT_WIDTH - ]) if (model.modelState.size === 'large' || model.modelState.size === 'medium') { model.modelState.captionWidth = ImageCaptionWidthTypes.IMAGE_WIDTH + } else { + model.setStateProp('captionWidth', ImageCaptionWidthTypes.IMAGE_WIDTH, p => p.toLowerCase(), [ + ImageCaptionWidthTypes.IMAGE_WIDTH, + ImageCaptionWidthTypes.TEXT_WIDTH + ]) } }, diff --git a/packages/obonode/obojobo-chunks-figure/choose-image-modal.js b/packages/obonode/obojobo-chunks-figure/choose-image-modal.js index f54b1db53d..113a4dd6fa 100644 --- a/packages/obonode/obojobo-chunks-figure/choose-image-modal.js +++ b/packages/obonode/obojobo-chunks-figure/choose-image-modal.js @@ -3,9 +3,9 @@ import './choose-image-modal.scss' import React from 'react' import API from 'obojobo-document-engine/src/scripts/viewer/util/api' -import Common from 'obojobo-document-engine/src/scripts/common' -const { SimpleDialog } = Common.components.modal +import SettingsDialog from 'obojobo-document-engine/src/scripts/common/components/modal/settings-dialog' +import SettingsDialogRow from 'obojobo-document-engine/src/scripts/common/components/modal/settings-dialog-row' import { uploadFileViaImageNode } from './utils' const IMAGE_BATCH_SIZE = 11 // load 11 images at a time @@ -78,15 +78,14 @@ class ChooseImageModal extends React.Component { : `${this.state.media.length} Recent Images loaded${this.state.hasMore ? ' with more' : ''}` return ( - this.props.onCloseChooseImageModal(this.state.url)} onCancel={() => this.props.onCloseChooseImageModal(null)} focusOnFirstElement={this.focusOnFirstElement} >
-
+
-
+
Your Recently Uploaded Images
- + ) } } diff --git a/packages/obonode/obojobo-chunks-figure/choose-image-modal.scss b/packages/obonode/obojobo-chunks-figure/choose-image-modal.scss index acbe2e0359..5f027a2417 100644 --- a/packages/obonode/obojobo-chunks-figure/choose-image-modal.scss +++ b/packages/obonode/obojobo-chunks-figure/choose-image-modal.scss @@ -4,9 +4,10 @@ $color-button-bg: lighten($color-action, 9%); text-align: center; - width: 27rem; + max-width: 27rem; background-color: $color-bg2; padding: 1rem; + margin: 0 auto; .choose-image--image-controls { display: flex; diff --git a/packages/obonode/obojobo-chunks-figure/editor-registration.js b/packages/obonode/obojobo-chunks-figure/editor-registration.js index 72230b286c..b061dd2206 100644 --- a/packages/obonode/obojobo-chunks-figure/editor-registration.js +++ b/packages/obonode/obojobo-chunks-figure/editor-registration.js @@ -58,6 +58,14 @@ const Figure = { }, onKeyDown(entry, editor, event) { if (event.key === 'Enter') return KeyDownUtil.breakToText(event, editor, entry) + + if (event.key === 'Delete') { + const text = entry[0].children[0].text + + if (editor.selection.anchor.offset === text.length) { + event.preventDefault() + } + } }, renderNode(props) { return diff --git a/packages/obonode/obojobo-chunks-figure/editor-registration.test.js b/packages/obonode/obojobo-chunks-figure/editor-registration.test.js index 35c059562a..cdc3a06863 100644 --- a/packages/obonode/obojobo-chunks-figure/editor-registration.test.js +++ b/packages/obonode/obojobo-chunks-figure/editor-registration.test.js @@ -63,7 +63,7 @@ describe('Figure editor', () => { expect(next).not.toHaveBeenCalled() }) - test('plugins.decorate exits when not relevent', () => { + test('plugins.decorate exits when not relevant', () => { expect(Figure.plugins.decorate([{ text: 'mock text' }], {})).toMatchSnapshot() expect(Figure.plugins.decorate([{ children: [{ text: 'mock text' }] }], {})).toMatchSnapshot() @@ -105,6 +105,29 @@ describe('Figure editor', () => { expect(KeyDownUtil.breakToText).toHaveBeenCalled() }) + test('plugins.onKeyDown deals with [Delete]', () => { + const editor = { + selection: { + anchor: { + path: [0, 0], + offset: 0 + } + }, + children: [] + } + + const event = { + key: 'Delete', + preventDefault: jest.fn() + } + + Figure.plugins.onKeyDown([{ children: [{ text: 'mockText' }] }, [0]], editor, event) + expect(event.preventDefault).not.toHaveBeenCalled() + + Figure.plugins.onKeyDown([{ children: [{ text: '' }] }, [0]], editor, event) + expect(event.preventDefault).toHaveBeenCalledTimes(1) + }) + test('plugins.renderNode renders a figure when passed', () => { const props = { attributes: { dummy: 'dummyData' }, diff --git a/packages/obonode/obojobo-chunks-figure/image-properties-modal.js b/packages/obonode/obojobo-chunks-figure/image-properties-modal.js index f34f1c042b..a811e8d818 100644 --- a/packages/obonode/obojobo-chunks-figure/image-properties-modal.js +++ b/packages/obonode/obojobo-chunks-figure/image-properties-modal.js @@ -8,7 +8,8 @@ import ImageCaptionWidthTypes from './image-caption-width-types' import Image from './image' import { isUUID } from './utils' -const { SimpleDialog } = Common.components.modal +import SettingsDialog from 'obojobo-document-engine/src/scripts/common/components/modal/settings-dialog' +import SettingsDialogRow from 'obojobo-document-engine/src/scripts/common/components/modal/settings-dialog-row' const { Button } = Common.components class ImageProperties extends React.Component { @@ -119,7 +120,7 @@ class ImageProperties extends React.Component { this.state.size === 'small' || this.state.size === 'custom' return ( - this.props.onConfirm(this.state)} @@ -127,7 +128,7 @@ class ImageProperties extends React.Component { >
-
+ Change Image... -
+ -
) : null}
-
+
- + ) } } diff --git a/packages/obonode/obojobo-chunks-figure/image-properties-modal.scss b/packages/obonode/obojobo-chunks-figure/image-properties-modal.scss index 8ae2a33c10..52530766a9 100644 --- a/packages/obonode/obojobo-chunks-figure/image-properties-modal.scss +++ b/packages/obonode/obojobo-chunks-figure/image-properties-modal.scss @@ -1,9 +1,14 @@ @import '~styles/includes'; +.obojobo-draft--components--modal--settings-dialog .image-properties .row { + margin-bottom: 0; +} + .image-properties { text-align: left; display: flex; width: 22em; + margin: 0 auto; &.is-size-large, &.is-size-medium { @@ -49,21 +54,26 @@ } .size-input { - display: block; + display: flex; + align-items: center; .custom-size-inputs { - display: inline; + display: flex; + align-items: center; padding-left: 0.5em; } - input[type='radio'] { - vertical-align: middle; + input { + margin-left: 0; + } + + span { + padding-right: 0.3em; } label { - display: inline; - vertical-align: middle; padding-left: 0.3em; + margin: 0; } } diff --git a/packages/obonode/obojobo-chunks-figure/package.json b/packages/obonode/obojobo-chunks-figure/package.json index cb51ec6cff..c311483fac 100644 --- a/packages/obonode/obojobo-chunks-figure/package.json +++ b/packages/obonode/obojobo-chunks-figure/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-figure", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Figure content chunk for Obojobo", "scripts": { @@ -27,7 +27,7 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-heading/package.json b/packages/obonode/obojobo-chunks-heading/package.json index 32babcc06e..2ea55e7042 100644 --- a/packages/obonode/obojobo-chunks-heading/package.json +++ b/packages/obonode/obojobo-chunks-heading/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-heading", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Heading content chunk for Obojobo", "scripts": { @@ -27,7 +27,7 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-html/package.json b/packages/obonode/obojobo-chunks-html/package.json index d9241eab42..58731582ac 100644 --- a/packages/obonode/obojobo-chunks-html/package.json +++ b/packages/obonode/obojobo-chunks-html/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-html", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "HTML content chunk for Obojobo", "scripts": { @@ -27,7 +27,7 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-iframe/package.json b/packages/obonode/obojobo-chunks-iframe/package.json index 49521e17e3..f2812a8df9 100644 --- a/packages/obonode/obojobo-chunks-iframe/package.json +++ b/packages/obonode/obojobo-chunks-iframe/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-iframe", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "IFrame content chunk for Obojobo", "scripts": { @@ -27,7 +27,7 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-list/package.json b/packages/obonode/obojobo-chunks-list/package.json index 0e7f935151..7b067e7312 100644 --- a/packages/obonode/obojobo-chunks-list/package.json +++ b/packages/obonode/obojobo-chunks-list/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-list", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "List content chunk for Obojobo", "scripts": { @@ -27,7 +27,7 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-materia/package.json b/packages/obonode/obojobo-chunks-materia/package.json index bd31bfff16..d41e7e2be5 100644 --- a/packages/obonode/obojobo-chunks-materia/package.json +++ b/packages/obonode/obojobo-chunks-materia/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-materia", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Materia content chunk for Obojobo", "scripts": { @@ -35,9 +35,9 @@ "lint-staged": "^10.5.4" }, "peerDependencies": { - "obojobo-chunks-iframe": "^14.0.0", - "obojobo-document-engine": "^14.0.0", - "obojobo-lib-utils": "^14.0.0" + "obojobo-chunks-iframe": "^15.0.0", + "obojobo-document-engine": "^15.0.0", + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-math-equation/package.json b/packages/obonode/obojobo-chunks-math-equation/package.json index dea0a61a20..e9acaa2b02 100644 --- a/packages/obonode/obojobo-chunks-math-equation/package.json +++ b/packages/obonode/obojobo-chunks-math-equation/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-math-equation", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "MathEquation content chunk for Obojobo", "scripts": { @@ -30,7 +30,7 @@ "katex": "^0.13.1" }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-multiple-choice-assessment/package.json b/packages/obonode/obojobo-chunks-multiple-choice-assessment/package.json index 0a596525d9..5289b11dfa 100644 --- a/packages/obonode/obojobo-chunks-multiple-choice-assessment/package.json +++ b/packages/obonode/obojobo-chunks-multiple-choice-assessment/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-multiple-choice-assessment", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "MCAssessment chunk for Obojobo", "scripts": { @@ -27,8 +27,8 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-chunks-question": "^14.0.0", - "obojobo-lib-utils": "^14.0.0" + "obojobo-chunks-question": "^15.0.0", + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-numeric-assessment/package.json b/packages/obonode/obojobo-chunks-numeric-assessment/package.json index 54738cac0f..cc9df402b0 100644 --- a/packages/obonode/obojobo-chunks-numeric-assessment/package.json +++ b/packages/obonode/obojobo-chunks-numeric-assessment/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-numeric-assessment", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "NumericAssessment chunk for Obojobo", "scripts": { @@ -30,8 +30,8 @@ "big.js": "^6.1.1" }, "peerDependencies": { - "obojobo-chunks-question": "^14.0.0", - "obojobo-lib-utils": "^14.0.0" + "obojobo-chunks-question": "^15.0.0", + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-question-bank/package.json b/packages/obonode/obojobo-chunks-question-bank/package.json index efb53d1a5c..8215d0c2ca 100644 --- a/packages/obonode/obojobo-chunks-question-bank/package.json +++ b/packages/obonode/obojobo-chunks-question-bank/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-question-bank", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "QuestionBank chunk for Obojobo", "scripts": { @@ -27,7 +27,7 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-question/package.json b/packages/obonode/obojobo-chunks-question/package.json index 1b68621c6c..f20e2dc8e8 100644 --- a/packages/obonode/obojobo-chunks-question/package.json +++ b/packages/obonode/obojobo-chunks-question/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-question", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Question content chunk for Obojobo", "scripts": { @@ -27,7 +27,7 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-table/package.json b/packages/obonode/obojobo-chunks-table/package.json index 3c22335520..98d4fb0eca 100644 --- a/packages/obonode/obojobo-chunks-table/package.json +++ b/packages/obonode/obojobo-chunks-table/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-table", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Table content chunk for Obojobo", "scripts": { @@ -27,7 +27,7 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-text/package.json b/packages/obonode/obojobo-chunks-text/package.json index be5281b9aa..9c64e8a3b4 100644 --- a/packages/obonode/obojobo-chunks-text/package.json +++ b/packages/obonode/obojobo-chunks-text/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-text", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Text content chunk for Obojobo", "scripts": { @@ -27,7 +27,7 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-text/viewer-component.scss b/packages/obonode/obojobo-chunks-text/viewer-component.scss index f6359aa90b..9bd4ae6cf6 100644 --- a/packages/obonode/obojobo-chunks-text/viewer-component.scss +++ b/packages/obonode/obojobo-chunks-text/viewer-component.scss @@ -8,4 +8,9 @@ .obo-text { display: block; } + + .align-justify { + text-align: justify; + white-space: pre-line; + } } diff --git a/packages/obonode/obojobo-chunks-youtube/__snapshots__/youtube-properties-modal.test.js.snap b/packages/obonode/obojobo-chunks-youtube/__snapshots__/youtube-properties-modal.test.js.snap index 8c723c47ce..e3749f6887 100644 --- a/packages/obonode/obojobo-chunks-youtube/__snapshots__/youtube-properties-modal.test.js.snap +++ b/packages/obonode/obojobo-chunks-youtube/__snapshots__/youtube-properties-modal.test.js.snap @@ -1,31 +1,31 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`YouTubeProperties modal Clicking on the clear times button collapses the UI and clears out times 1`] = `"

YouTube Video

"`; +exports[`YouTubeProperties modal Clicking on the clear times button collapses the UI and clears out times 1`] = `"

YouTube Video

"`; -exports[`YouTubeProperties modal Clicking on the edit times button expands the UI 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; +exports[`YouTubeProperties modal Clicking on the edit times button expands the UI 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; -exports[`YouTubeProperties modal Inputting a URL WITH start/end times (when start/end times are already defined) DOES CLEAR the start/end times 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; +exports[`YouTubeProperties modal Inputting a URL WITH start/end times (when start/end times are already defined) DOES CLEAR the start/end times 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; -exports[`YouTubeProperties modal Inputting a URL and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

"`; +exports[`YouTubeProperties modal Inputting a URL and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

"`; -exports[`YouTubeProperties modal Inputting a URL with end times and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; +exports[`YouTubeProperties modal Inputting a URL with end times and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; -exports[`YouTubeProperties modal Inputting a URL with start and end times and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; +exports[`YouTubeProperties modal Inputting a URL with start and end times and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; -exports[`YouTubeProperties modal Inputting a URL with start times and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; +exports[`YouTubeProperties modal Inputting a URL with start times and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; -exports[`YouTubeProperties modal Inputting a URL without start/end times (when start/end times are already defined) does not clear or modify the start/end times 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; +exports[`YouTubeProperties modal Inputting a URL without start/end times (when start/end times are already defined) does not clear or modify the start/end times 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; -exports[`YouTubeProperties modal Inputting a video ID and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

"`; +exports[`YouTubeProperties modal Inputting a video ID and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

"`; -exports[`YouTubeProperties modal Inputting an embed code and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

"`; +exports[`YouTubeProperties modal Inputting an embed code and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

"`; -exports[`YouTubeProperties modal Inputting times in MM:SS format works fine 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; +exports[`YouTubeProperties modal Inputting times in MM:SS format works fine 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; -exports[`YouTubeProperties modal Pasting a URL and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

"`; +exports[`YouTubeProperties modal Pasting a URL and clicking OK calls onConfirm with expected values 1`] = `"

YouTube Video

"`; -exports[`YouTubeProperties modal YouTubeProperties component focuses on first element 1`] = `"

YouTube Video

"`; +exports[`YouTubeProperties modal YouTubeProperties component focuses on first element 1`] = `"

YouTube Video

"`; -exports[`YouTubeProperties modal YouTubeProperties component renders correctly 1`] = `"

YouTube Video

"`; +exports[`YouTubeProperties modal YouTubeProperties component renders correctly 1`] = `"

YouTube Video

"`; -exports[`YouTubeProperties modal YouTubeProperties component with values renders correctly 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; +exports[`YouTubeProperties modal YouTubeProperties component with values renders correctly 1`] = `"

YouTube Video

Start & End Times

Seconds or MM:SS format (e.g. 135 or 2:15)Seconds or MM:SS format (e.g. 135 or 2:15)
"`; diff --git a/packages/obonode/obojobo-chunks-youtube/package.json b/packages/obonode/obojobo-chunks-youtube/package.json index 01de489a1a..04153fa12e 100644 --- a/packages/obonode/obojobo-chunks-youtube/package.json +++ b/packages/obonode/obojobo-chunks-youtube/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-chunks-youtube", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Youtube content chunk for Obojobo", "scripts": { @@ -27,7 +27,7 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-chunks-youtube/youtube-properties-modal.js b/packages/obonode/obojobo-chunks-youtube/youtube-properties-modal.js index 7b92bb2495..a47421269b 100644 --- a/packages/obonode/obojobo-chunks-youtube/youtube-properties-modal.js +++ b/packages/obonode/obojobo-chunks-youtube/youtube-properties-modal.js @@ -1,11 +1,10 @@ import './youtube-properties-modal.scss' import React from 'react' -import Common from 'obojobo-document-engine/src/scripts/common' import { parseYouTubeURL, getStandardizedURLFromVideoId } from './parse-youtube-url' import Button from 'obojobo-document-engine/src/scripts/common/components/button' -const { SimpleDialog } = Common.components.modal +import SettingsDialog from 'obojobo-document-engine/src/scripts/common/components/modal/settings-dialog' class YouTubeProperties extends React.Component { constructor(props) { @@ -172,12 +171,11 @@ class YouTubeProperties extends React.Component { render() { return ( -
)}
-
+ ) } } diff --git a/packages/obonode/obojobo-modules-module/package.json b/packages/obonode/obojobo-modules-module/package.json index 2c1cf585b7..e09bfb6a07 100644 --- a/packages/obonode/obojobo-modules-module/package.json +++ b/packages/obonode/obojobo-modules-module/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-modules-module", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Default Module node for Obojobo", "scripts": { @@ -27,7 +27,7 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-pages-page/package.json b/packages/obonode/obojobo-pages-page/package.json index b78eae9218..a3488442c1 100644 --- a/packages/obonode/obojobo-pages-page/package.json +++ b/packages/obonode/obojobo-pages-page/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-pages-page", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Page chunk for Obojobo", "scripts": { @@ -27,8 +27,8 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-document-engine": "^14.0.0", - "obojobo-lib-utils": "^14.0.0" + "obojobo-document-engine": "^15.0.0", + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-sections-assessment/components/rubric/__snapshots__/rubric-modal.test.js.snap b/packages/obonode/obojobo-sections-assessment/components/rubric/__snapshots__/rubric-modal.test.js.snap index 72455a2ee5..3bd9528958 100644 --- a/packages/obonode/obojobo-sections-assessment/components/rubric/__snapshots__/rubric-modal.test.js.snap +++ b/packages/obonode/obojobo-sections-assessment/components/rubric/__snapshots__/rubric-modal.test.js.snap @@ -88,7 +88,7 @@ exports[`Rubric editor modal Rubric modal renders 1`] = ` Pass & Fail Rules

- In this mode, you can customize passing and failing scores, what do do when students are out of attempts, and set more complex penalty and extra credit rules. + In this mode, you can customize passing and failing scores, what to do when students are out of attempts, and set more complex penalty and extra credit rules.

- In this mode, you can customize passing and failing scores, what do do when students are out of attempts, and set more complex penalty and extra credit rules. + In this mode, you can customize passing and failing scores, what to do when students are out of attempts, and set more complex penalty and extra credit rules.

- In this mode, you can customize passing and failing scores, what do do when students are out of attempts, and set more complex penalty and extra credit rules. + In this mode, you can customize passing and failing scores, what to do when students are out of attempts, and set more complex penalty and extra credit rules.

Pass & Fail Rules

- In this mode, you can customize passing and failing scores, what do do when + In this mode, you can customize passing and failing scores, what to do when students are out of attempts, and set more complex penalty and extra credit rules.

diff --git a/packages/obonode/obojobo-sections-assessment/package.json b/packages/obonode/obojobo-sections-assessment/package.json index 49136d233f..5831ac6e94 100644 --- a/packages/obonode/obojobo-sections-assessment/package.json +++ b/packages/obonode/obojobo-sections-assessment/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-sections-assessment", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Assessment section for Obojobo", "scripts": { @@ -27,8 +27,8 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-document-engine": "^14.0.0", - "obojobo-lib-utils": "^14.0.0" + "obojobo-document-engine": "^15.0.0", + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/obonode/obojobo-sections-content/package.json b/packages/obonode/obojobo-sections-content/package.json index 138a58f6c5..a79dfea910 100644 --- a/packages/obonode/obojobo-sections-content/package.json +++ b/packages/obonode/obojobo-sections-content/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-sections-content", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Content section chunk for Obojobo", "scripts": { @@ -27,7 +27,7 @@ "singleQuote": true }, "peerDependencies": { - "obojobo-lib-utils": "^14.0.0" + "obojobo-lib-utils": "^15.0.0" }, "jest": { "testMatch": [ diff --git a/packages/util/eslint-config-obojobo/package.json b/packages/util/eslint-config-obojobo/package.json index 517ee2d652..7906c9a08c 100644 --- a/packages/util/eslint-config-obojobo/package.json +++ b/packages/util/eslint-config-obojobo/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-obojobo", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "scripts": { "lint": "echo 'not implemented'", diff --git a/packages/util/obojobo-lib-utils/package.json b/packages/util/obojobo-lib-utils/package.json index 4ce4fa6894..e7a30918a8 100644 --- a/packages/util/obojobo-lib-utils/package.json +++ b/packages/util/obojobo-lib-utils/package.json @@ -1,6 +1,6 @@ { "name": "obojobo-lib-utils", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "description": "Assortment of reusable parts for obojobo libraries.", "scripts": { diff --git a/packages/util/stylelint-config-obojobo/package.json b/packages/util/stylelint-config-obojobo/package.json index 1138ea0e79..0b87c5cf85 100644 --- a/packages/util/stylelint-config-obojobo/package.json +++ b/packages/util/stylelint-config-obojobo/package.json @@ -1,6 +1,6 @@ { "name": "stylelint-config-obojobo", - "version": "14.0.0", + "version": "15.0.0", "license": "AGPL-3.0-only", "scripts": { "lint": "echo 'not implemented'", diff --git a/yarn.lock b/yarn.lock index 0d23605d54..7b643ab952 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1183,6 +1183,11 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@gar/promisify@^1.0.1": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" + integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -2080,6 +2085,14 @@ resolved "https://registry.yarnpkg.com/@npmcli/ci-detect/-/ci-detect-1.3.0.tgz#6c1d2c625fb6ef1b9dea85ad0a5afcbef85ef22a" integrity sha512-oN3y7FAROHhrAt7Rr7PnTSwrHrZVRTS2ZbyxeQwSSYD0ifwM3YNgQqbaRmjcWoPyq77MjchusjJDspbzMmip1Q== +"@npmcli/fs@^1.0.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-1.1.1.tgz#72f719fe935e687c56a4faecf3c03d06ba593257" + integrity sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ== + dependencies: + "@gar/promisify" "^1.0.1" + semver "^7.3.5" + "@npmcli/git@^2.0.1": version "2.0.6" resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-2.0.6.tgz#47b97e96b2eede3f38379262fa3bdfa6eae57bf2" @@ -2850,7 +2863,7 @@ add-stream@^1.0.0: resolved "https://registry.yarnpkg.com/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa" integrity sha1-anmQQ3ynNtXhKI25K9MmbV9csqo= -agent-base@6: +agent-base@6, agent-base@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== @@ -2973,6 +2986,11 @@ ansi-regex@^5.0.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -2992,6 +3010,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +any-base@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/any-base/-/any-base-1.1.0.tgz#ae101a62bc08a597b4c9ab5b7089d456630549fe" + integrity sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg== + anymatch@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" @@ -3018,11 +3041,27 @@ aproba@^1.0.3: resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== -aproba@^2.0.0: +"aproba@^1.0.3 || ^2.0.0", aproba@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== +are-we-there-yet@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" + integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + +are-we-there-yet@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-3.0.0.tgz#ba20bd6b553e31d62fc8c31bd23d22b95734390d" + integrity sha512-0GWpv50YSOcLXaN6/FAKY3vfRbllXWV2xvfA/oKJF8pzFhWXPV+yjhJXDBbjscDYowv7Yw1A3uigpzn5iEGTyw== + dependencies: + delegates "^1.0.0" + readable-stream "^3.6.0" + are-we-there-yet@~1.1.2: version "1.1.5" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" @@ -3205,11 +3244,6 @@ async-limiter@~1.0.0: resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== -async@0.9.x, async@~0.9.0: - version "0.9.2" - resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" - integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0= - async@^2.6.2: version "2.6.3" resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff" @@ -3217,6 +3251,16 @@ async@^2.6.2: dependencies: lodash "^4.17.14" +async@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9" + integrity sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g== + +async@~0.9.0: + version "0.9.2" + resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d" + integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0= + async@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/async/-/async-1.0.0.tgz#f8fc04ca3a13784ade9e1641af98578cfbd647a9" @@ -4275,6 +4319,30 @@ cacache@^15.0.5: tar "^6.0.2" unique-filename "^1.1.1" +cacache@^15.2.0: + version "15.3.0" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.3.0.tgz#dc85380fb2f556fe3dda4c719bfa0ec875a7f1eb" + integrity sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ== + dependencies: + "@npmcli/fs" "^1.0.0" + "@npmcli/move-file" "^1.0.1" + chownr "^2.0.0" + fs-minipass "^2.0.0" + glob "^7.1.4" + infer-owner "^1.0.4" + lru-cache "^6.0.0" + minipass "^3.1.1" + minipass-collect "^1.0.2" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.2" + mkdirp "^1.0.3" + p-map "^4.0.0" + promise-inflight "^1.0.1" + rimraf "^3.0.2" + ssri "^8.0.1" + tar "^6.0.2" + unique-filename "^1.1.1" + cache-base@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" @@ -4422,7 +4490,7 @@ ccount@^1.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" integrity sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg== -chalk@^1.1.1, chalk@^1.1.3: +chalk@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98" integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg= @@ -4450,10 +4518,10 @@ chalk@^3.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^4.0.0, chalk@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a" - integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A== +chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== dependencies: ansi-styles "^4.1.0" supports-color "^7.1.0" @@ -4724,7 +4792,7 @@ collection-visit@^1.0.0: map-visit "^1.0.0" object-visit "^1.0.0" -color-convert@^1.9.0, color-convert@^1.9.1: +color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== @@ -4748,21 +4816,26 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.5.4: - version "1.5.5" - resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.5.tgz#65474a8f0e7439625f3d27a6a19d89fc45223014" - integrity sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg== +color-string@^1.9.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" + integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== dependencies: color-name "^1.0.0" simple-swizzle "^0.2.2" -color@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/color/-/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e" - integrity sha512-xgXAcTHa2HeFCGLE9Xs/R82hujGtu9Jd9x4NW3T34+OMs7VoPsjwzRczKHvTAHeJwWFwX5j15+MgAppE8ztObQ== +color-support@^1.1.2, color-support@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" + integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== + +color@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" + integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== dependencies: - color-convert "^1.9.1" - color-string "^1.5.4" + color-convert "^2.0.1" + color-string "^1.9.0" colorette@^1.2.1, colorette@^1.2.2: version "1.2.2" @@ -4910,10 +4983,10 @@ connect-pg-simple@^6.2.1: "@types/pg" "^7.14.4" pg "^8.2.1" -console-control-strings@^1.0.0, console-control-strings@~1.1.0: +console-control-strings@^1.0.0, console-control-strings@^1.1.0, console-control-strings@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= + integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== consolidate@^0.16.0: version "0.16.0" @@ -5391,6 +5464,13 @@ debug@^3.1.1, debug@^3.2.6: dependencies: ms "^2.1.1" +debug@^4.3.3: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" @@ -5433,6 +5513,13 @@ decompress-response@^4.2.0: dependencies: mimic-response "^2.0.0" +decompress-response@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" + integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== + dependencies: + mimic-response "^3.1.0" + dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -5594,11 +5681,16 @@ detect-indent@^6.0.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd" integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA== -detect-libc@^1.0.2, detect-libc@^1.0.3: +detect-libc@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= +detect-libc@^2.0.0, detect-libc@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" + integrity sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w== + detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -5819,11 +5911,11 @@ ee-first@1.1.1: integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= ejs@^3.1.6: - version "3.1.6" - resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.6.tgz#5bfd0a0689743bb5268b3550cceeebbc1702822a" - integrity sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw== + version "3.1.7" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.7.tgz#c544d9c7f715783dd92f0bddcf73a59e6962d006" + integrity sha512-BIar7R6abbUxDA3bfXrO4DSgwo8I+fB5/1zgujl3HLLjwd6+9iOnrT+t3grn2qbk9vOgBubXOFwX2m9axoFaGw== dependencies: - jake "^10.6.1" + jake "^10.8.5" electron-to-chromium@^1.3.47, electron-to-chromium@^1.3.649: version "1.3.699" @@ -6220,9 +6312,9 @@ events@^3.2.0: integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== eventsource@^1.0.7: - version "1.1.0" - resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.1.0.tgz#00e8ca7c92109e94b0ddf32dac677d841028cfaf" - integrity sha512-VSJjT5oCNrFvCS6igjzPAt5hBzQ2qPBFIbJ03zLI9SE0mxwZpMw6BfJrbFHm1a141AavMEB8JHmBhWAd66PfCg== + version "1.1.1" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-1.1.1.tgz#4544a35a57d7120fba4fa4c86cb4023b2c09df2f" + integrity sha512-qV5ZC0h7jYIAOhArFJgSfdyz6rALJyb270714o7ZtNnw2WSJ+eexhKtE0O8LYPRsHZHf2osHKZBxGPvm3kPkCA== dependencies: original "^1.0.0" @@ -6687,9 +6779,9 @@ flatted@^3.1.0: integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA== follow-redirects@^1.0.0: - version "1.13.3" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267" - integrity sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA== + version "1.14.8" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc" + integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA== for-in@^1.0.2: version "1.0.2" @@ -6813,6 +6905,35 @@ functions-have-names@^1.2.2: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.2.tgz#98d93991c39da9361f8e50b337c4f6e41f120e21" integrity sha512-bLgc3asbWdwPbx2mNk2S49kmJCuQeu0nfmaOgbs8WIyzzkw3r4htszdIi9Q9EMezDPTYuJx2wvjZ/EwgAthpnA== +gauge@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" + integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.2" + console-control-strings "^1.0.0" + has-unicode "^2.0.1" + object-assign "^4.1.1" + signal-exit "^3.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.2" + +gauge@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/gauge/-/gauge-4.0.4.tgz#52ff0652f2bbf607a989793d53b751bef2328dce" + integrity sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg== + dependencies: + aproba "^1.0.3 || ^2.0.0" + color-support "^1.1.3" + console-control-strings "^1.1.0" + has-unicode "^2.0.1" + signal-exit "^3.0.7" + string-width "^4.2.3" + strip-ansi "^6.0.1" + wide-align "^1.1.5" + gauge@~2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" @@ -7156,9 +7277,9 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6 integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== graceful-fs@^4.2.6: - version "4.2.8" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" - integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== + version "4.2.10" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" + integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== growly@^1.3.0: version "1.3.0" @@ -8269,13 +8390,13 @@ istanbul-reports@^3.0.2: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -jake@^10.6.1: - version "10.8.2" - resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b" - integrity sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A== +jake@^10.8.5: + version "10.8.5" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46" + integrity sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw== dependencies: - async "0.9.x" - chalk "^2.4.2" + async "^3.2.3" + chalk "^4.0.2" filelist "^1.0.1" minimatch "^3.0.4" @@ -9279,7 +9400,7 @@ make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0: dependencies: semver "^6.0.0" -make-fetch-happen@^8.0.14, make-fetch-happen@^8.0.9: +make-fetch-happen@^8.0.9: version "8.0.14" resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-8.0.14.tgz#aaba73ae0ab5586ad8eaa68bd83332669393e222" integrity sha512-EsS89h6l4vbfJEtBZnENTOFk8mCRpY5ru36Xe5bcX1KYIli2mkSHqoFsp5O1wMDvTJJzxe/4THpCTtygjeeGWQ== @@ -9300,6 +9421,28 @@ make-fetch-happen@^8.0.14, make-fetch-happen@^8.0.9: socks-proxy-agent "^5.0.0" ssri "^8.0.0" +make-fetch-happen@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz#53085a09e7971433e6765f7971bf63f4e05cb968" + integrity sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg== + dependencies: + agentkeepalive "^4.1.3" + cacache "^15.2.0" + http-cache-semantics "^4.1.0" + http-proxy-agent "^4.0.1" + https-proxy-agent "^5.0.0" + is-lambda "^1.0.1" + lru-cache "^6.0.0" + minipass "^3.1.3" + minipass-collect "^1.0.2" + minipass-fetch "^1.3.2" + minipass-flush "^1.0.5" + minipass-pipeline "^1.2.4" + negotiator "^0.6.2" + promise-retry "^2.0.1" + socks-proxy-agent "^6.0.0" + ssri "^8.0.0" + makeerror@1.0.x: version "1.0.11" resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c" @@ -9407,7 +9550,7 @@ memory-fs@^0.4.1: errno "^0.1.3" readable-stream "^2.0.1" -meow@^3.3.0, meow@^3.7.0: +meow@^3.3.0: version "3.7.0" resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs= @@ -9565,6 +9708,11 @@ mimic-response@^2.0.0: resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== +mimic-response@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" + integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== + min-indent@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" @@ -9609,9 +9757,9 @@ minimist-options@^3.0.1: is-plain-obj "^1.1.0" minimist@^1.1.1, minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== minipass-collect@^1.0.2: version "1.0.2" @@ -9730,9 +9878,9 @@ modify-values@^1.0.0: integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== moment@^2.29.1: - version "2.29.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" - integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + version "2.29.2" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.2.tgz#00910c60b20843bcba52d37d58c628b47b1f20e4" + integrity sha512-UgzG4rvxYpN15jgCmVJwac49h9ly9NurikMWGPdVxm8GZD6XjkKPxDTjQQ43gtGgnV3X0cAyWDdP2Wexoquifg== "mongodb-uri@>= 0.9.7": version "0.9.7" @@ -9889,6 +10037,11 @@ negotiator@0.6.2: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== +negotiator@^0.6.2: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + neo-async@^2.6.0, neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" @@ -9899,17 +10052,17 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -node-abi@^2.21.0: - version "2.21.0" - resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-2.21.0.tgz#c2dc9ebad6f4f53d6ea9b531e7b8faad81041d48" - integrity sha512-smhrivuPqEM3H5LmnY3KU6HfYv0u4QklgAxfFyRNujKUzbUcYZ+Jc2EhukB9SRcD2VpqhxM7n/MIcp1Ua1/JMg== +node-abi@^3.3.0: + version "3.22.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.22.0.tgz#00b8250e86a0816576258227edbce7bbe0039362" + integrity sha512-u4uAs/4Zzmp/jjsD9cyFYDXeISfUWaAVWshPmDZOFOv4Xl4SbzTXm53I04C2uRueYJ+0t5PEtLH/owbn2Npf/w== dependencies: - semver "^5.4.1" + semver "^7.3.5" -node-addon-api@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.1.0.tgz#98b21931557466c6729e51cb77cd39c965f42239" - integrity sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw== +node-addon-api@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-5.0.0.tgz#7d7e6f9ef89043befdb20c1989c905ebde18c501" + integrity sha512-CvkDw2OEnme7ybCykJpVcKH+uAOLV2qLqiyla128dN9TkEWfrYmxG6C2boDe5KcNQqZF3orkqzGgOMvZ/JNekA== node-fetch@^2.6.1: version "2.6.7" @@ -9929,16 +10082,16 @@ node-fs@~0.1.5: integrity sha1-MjI8zLRsn78PwRgS1FAhzDHTJbs= node-gyp@^5.0.2, node-gyp@^7.1.0, node-gyp@^8.0.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-8.2.0.tgz#ef509ccdf5cef3b4d93df0690b90aa55ff8c7977" - integrity sha512-KG8SdcoAnw2d6augGwl1kOayALUrXW/P2uOAm2J2+nmW/HjZo7y+8TDg7LejxbekOOSv3kzhq+NSUYkIDAX8eA== + version "8.4.1" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-8.4.1.tgz#3d49308fc31f768180957d6b5746845fbd429937" + integrity sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w== dependencies: env-paths "^2.2.0" glob "^7.1.4" graceful-fs "^4.2.6" - make-fetch-happen "^8.0.14" + make-fetch-happen "^9.1.0" nopt "^5.0.0" - npmlog "^4.1.2" + npmlog "^6.0.0" rimraf "^3.0.2" semver "^7.3.5" tar "^6.1.2" @@ -9987,23 +10140,22 @@ node-releases@^1.1.70: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb" integrity sha512-zR6HoT6LrLCRBwukmrVbHv0EpEQjksO6GmFcZQQuCAy139BEsoVKPYnf3jongYW83fAa1torLGYwxxky/p28sg== -node-sass@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-5.0.0.tgz#4e8f39fbef3bac8d2dc72ebe3b539711883a78d2" - integrity sha512-opNgmlu83ZCF792U281Ry7tak9IbVC+AKnXGovcQ8LG8wFaJv6cLnRlc6DIHlmNxWEexB5bZxi9SZ9JyUuOYjw== +node-sass@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-7.0.0.tgz#33ee7c2df299d51f682f13d79f3d2a562225788e" + integrity sha512-6yUnsD3L8fVbgMX6nKQqZkjRcG7a/PpmF0pEyeWf+BgbTj2ToJlCYrnUifL2KbjV5gIY22I3oppahBWA3B+jUg== dependencies: async-foreach "^0.1.3" - chalk "^1.1.1" + chalk "^4.1.2" cross-spawn "^7.0.3" gaze "^1.0.0" get-stdin "^4.0.1" glob "^7.0.3" lodash "^4.17.15" - meow "^3.7.0" - mkdirp "^0.5.1" + meow "^9.0.0" nan "^2.13.2" node-gyp "^7.1.0" - npmlog "^4.0.0" + npmlog "^5.0.0" request "^2.88.0" sass-graph "2.2.5" stdout-stream "^1.4.0" @@ -10025,11 +10177,6 @@ nodemon@^2.0.4: undefsafe "^2.0.3" update-notifier "^4.1.0" -noop-logger@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/noop-logger/-/noop-logger-0.1.1.tgz#94a2b1633c4f1317553007d8966fd0e841b6a4c2" - integrity sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI= - nopt@^4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.3.tgz#a375cad9d02fd921278d954c2254d5aa57e15e48" @@ -10203,7 +10350,7 @@ npm-run-path@^4.0.0, npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" -npmlog@^4.0.0, npmlog@^4.0.1, npmlog@^4.0.2, npmlog@^4.1.2: +npmlog@^4.0.1, npmlog@^4.0.2, npmlog@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -10213,6 +10360,26 @@ npmlog@^4.0.0, npmlog@^4.0.1, npmlog@^4.0.2, npmlog@^4.1.2: gauge "~2.7.3" set-blocking "~2.0.0" +npmlog@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" + integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== + dependencies: + are-we-there-yet "^2.0.0" + console-control-strings "^1.1.0" + gauge "^3.0.0" + set-blocking "^2.0.0" + +npmlog@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-6.0.2.tgz#c8166017a42f2dea92d6453168dd865186a70830" + integrity sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg== + dependencies: + are-we-there-yet "^3.0.0" + console-control-strings "^1.1.0" + gauge "^4.0.3" + set-blocking "^2.0.0" + nth-check@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" @@ -11115,23 +11282,22 @@ postgres-interval@^1.1.0: dependencies: xtend "^4.0.0" -prebuild-install@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-6.1.1.tgz#6754fa6c0d55eced7f9e14408ff9e4cba6f097b4" - integrity sha512-M+cKwofFlHa5VpTWub7GLg5RLcunYIcLqtY5pKcls/u7xaAb8FrXZ520qY8rkpYy5xw90tYCyMO0MP5ggzR3Sw== +prebuild-install@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-7.1.0.tgz#991b6ac16c81591ba40a6d5de93fb33673ac1370" + integrity sha512-CNcMgI1xBypOyGqjp3wOc8AAo1nMhZS3Cwd3iHIxOdAUbb+YxdNuM4Z5iIrZ8RLvOsf3F3bl7b7xGq6DjQoNYA== dependencies: - detect-libc "^1.0.3" + detect-libc "^2.0.0" expand-template "^2.0.3" github-from-package "0.0.0" minimist "^1.2.3" mkdirp-classic "^0.5.3" napi-build-utils "^1.0.1" - node-abi "^2.21.0" - noop-logger "^0.1.1" + node-abi "^3.3.0" npmlog "^4.0.1" pump "^3.0.0" rc "^1.2.7" - simple-get "^3.0.3" + simple-get "^4.0.0" tar-fs "^2.0.0" tunnel-agent "^0.6.0" @@ -12349,7 +12515,7 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" -"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1: +"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.5.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -12364,10 +12530,10 @@ semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== -semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5: - version "7.3.5" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7" - integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ== +semver@^7.1.1, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7: + version "7.3.7" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f" + integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g== dependencies: lru-cache "^6.0.0" @@ -12468,17 +12634,17 @@ shallowequal@^1.1.0: resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== -sharp@^0.28.1: - version "0.28.1" - resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.28.1.tgz#9d7bbce1ca95b2c27482243cd4839c62ef40b0b7" - integrity sha512-4mCGMEN4ntaVuFGwHx7FvkJQkIgbI+S+F9a3bI7ugdvKjPr4sF7/ibvlRKhJyzhoQi+ODM+XYY1de8xs7MHbfA== - dependencies: - color "^3.1.3" - detect-libc "^1.0.3" - node-addon-api "^3.1.0" - prebuild-install "^6.1.1" - semver "^7.3.5" - simple-get "^3.1.0" +sharp@^0.30.5: + version "0.30.5" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.30.5.tgz#81c36fd05624978384ac6bd2744d23f9c82edefd" + integrity sha512-0T28KxqY4DzUMLSAp1/IhGVeHpPIQyp1xt7esmuXCAfyi/+6tYMUeRhQok+E/+E52Yk5yFjacXp90cQOkmkl4w== + dependencies: + color "^4.2.3" + detect-libc "^2.0.1" + node-addon-api "^5.0.0" + prebuild-install "^7.1.0" + semver "^7.3.7" + simple-get "^4.0.1" tar-fs "^2.1.1" tunnel-agent "^0.6.0" @@ -12520,6 +12686,14 @@ shellwords@^0.1.1: resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== +short-uuid@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/short-uuid/-/short-uuid-3.1.1.tgz#3ff427074b5fa7822c3793994d18a7a82e2f73a4" + integrity sha512-7dI69xtJYpTIbg44R6JSgrbDtZFuZ9vAwwmnF/L0PinykbFrhQ7V8omKsQcVw1TP0nYJ7uQp1PN6/aVMkzQFGQ== + dependencies: + any-base "^1.1.0" + uuid "^3.3.2" + shortid@^2.2.16: version "2.2.16" resolved "https://registry.yarnpkg.com/shortid/-/shortid-2.2.16.tgz#b742b8f0cb96406fd391c76bfc18a67a57fe5608" @@ -12546,20 +12720,34 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== +signal-exit@^3.0.7: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + simple-concat@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== -simple-get@^3.0.3, simple-get@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3" - integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA== +simple-get@^3.0.3: + version "3.1.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.1.tgz#cc7ba77cfbe761036fbfce3d021af25fc5584d55" + integrity sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA== dependencies: decompress-response "^4.2.0" once "^1.3.1" simple-concat "^1.0.0" +simple-get@^4.0.0, simple-get@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.1.tgz#4a39db549287c979d352112fa03fd99fd6bc3543" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" + simple-swizzle@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" @@ -12661,6 +12849,11 @@ smart-buffer@^4.1.0: resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.1.0.tgz#91605c25d91652f4661ea69ccf45f1b331ca21ba" integrity sha512-iVICrxOzCynf/SNaBQCw34eM9jROU/s5rzIhpOvzhzuYHfJR/DhZfDkXiZSgKXfgv26HT3Yni3AV/DGw0cGnnw== +smart-buffer@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" + integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== + smoothscroll-polyfill@^0.4.4: version "0.4.4" resolved "https://registry.yarnpkg.com/smoothscroll-polyfill/-/smoothscroll-polyfill-0.4.4.tgz#3a259131dc6930e6ca80003e1cb03b603b69abf8" @@ -12726,6 +12919,15 @@ socks-proxy-agent@^5.0.0: debug "4" socks "^2.3.3" +socks-proxy-agent@^6.0.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz#2687a31f9d7185e38d530bef1944fe1f1496d6ce" + integrity sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ== + dependencies: + agent-base "^6.0.2" + debug "^4.3.3" + socks "^2.6.2" + socks@^2.3.3: version "2.6.0" resolved "https://registry.yarnpkg.com/socks/-/socks-2.6.0.tgz#6b984928461d39871b3666754b9000ecf39dfac2" @@ -12734,6 +12936,14 @@ socks@^2.3.3: ip "^1.1.5" smart-buffer "^4.1.0" +socks@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.6.2.tgz#ec042d7960073d40d94268ff3bb727dc685f111a" + integrity sha512-zDZhHhZRY9PxRruRMR7kMhnf3I8hDs4S3f9RecfnGxvcBHQcKcIH/oUcEWffsfl1XxdYlA7nnlGbbTvPz9D8gA== + dependencies: + ip "^1.1.5" + smart-buffer "^4.2.0" + sort-keys@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-2.0.0.tgz#658535584861ec97d730d6cf41822e1f56684128" @@ -13021,6 +13231,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^3.0.0, string-width@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" @@ -13143,6 +13362,13 @@ strip-ansi@^6.0.0: dependencies: ansi-regex "^5.0.0" +strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-bom@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e" @@ -13484,9 +13710,9 @@ symbol-tree@^3.2.4: integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== sysend@^1.3.5: - version "1.3.5" - resolved "https://registry.yarnpkg.com/sysend/-/sysend-1.3.5.tgz#066606414db9798fa21e89db49ec0a400f852829" - integrity sha512-ZafJNSArHnTPYOvX3gym+Nwr7ZQCoqbrY29mdj0RmtHvnqeNQz/HvJDq1+NtmdnPEymMMWLS74SleHw9gDVswA== + version "1.10.0" + resolved "https://registry.yarnpkg.com/sysend/-/sysend-1.10.0.tgz#af5f7d52b00947563fc9f4e2fbeb6db52912dd41" + integrity sha512-kQDpqW60fvgbNLnWnTRaJ2KGX3aW5fThu9St8T4h+j8XC1YIDXhWVqS8Ho+WYgasmtrP7RvcRRhs+SVCb9o7wA== table@^5.2.3: version "5.4.6" @@ -14217,9 +14443,9 @@ url-parse-lax@^3.0.0: prepend-http "^2.0.0" url-parse@^1.4.3, url-parse@^1.4.7, url-parse@^1.5.1: - version "1.5.2" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.2.tgz#a4eff6fd5ff9fe6ab98ac1f79641819d13247cda" - integrity sha512-6bTUPERy1muxxYClbzoRo5qtQuyoGEbzbQvi0SW4/8U8UyVkAQhWFBlnigqJkRm4su4x1zDQfNbEzWkt+vchcg== + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== dependencies: querystringify "^2.1.1" requires-port "^1.0.0" @@ -14673,6 +14899,13 @@ wide-align@^1.1.0: dependencies: string-width "^1.0.2 || 2" +wide-align@^1.1.2, wide-align@^1.1.5: + version "1.1.5" + resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" + integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== + dependencies: + string-width "^1.0.2 || 2 || 3 || 4" + widest-line@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca"