From 3677b6a0971d01a268b91ab7c24bf5b684ebdfa6 Mon Sep 17 00:00:00 2001 From: Brandon Stull Date: Thu, 6 Apr 2023 10:07:01 -0400 Subject: [PATCH 1/3] #2005 Adds additional functionality to check a user's permissions before rendering copy/preview buttons in a module's public page. Adds 'normal' and 'read-only' copy options to a module's public page. Replaces 'edit' button with 'synchronize' button in module dashboard for read-only modules. Adds a new dialog and relevant backend logic to automatically synchronize a read-only copied module with its original if any changes have been made. --- .../express_current_document.test.js | 57 ++++ .../__tests__/express_validators.test.js | 31 ++ .../__tests__/routes/editor.test.js | 3 + .../server/express_current_document.js | 36 ++- .../server/express_validators.js | 11 + .../obojobo-express/server/routes/editor.js | 11 +- .../server/models/draft_permissions.js | 20 +- .../server/models/draft_permissions.test.js | 100 +++++- .../server/models/draft_summary.js | 26 +- .../server/models/draft_summary.test.js | 58 ++++ .../server/models/drafts_metadata.js | 60 ++++ .../server/models/drafts_metadata.test.js | 187 ++++++++++- .../obojobo-repository/server/routes/api.js | 79 ++++- .../server/routes/api.test.js | 209 +++++++++++- .../server/routes/library.js | 8 +- .../server/routes/library.test.js | 4 +- .../shared/actions/dashboard-actions.js | 46 +++ .../shared/actions/dashboard-actions.test.js | 105 ++++-- .../app/obojobo-repository/shared/api-util.js | 4 +- .../shared/api-util.test.js | 6 +- .../__snapshots__/module-menu.test.js.snap | 206 +++++++++++- .../module-sync-dialog.test.js.snap | 204 ++++++++++++ .../shared/components/dashboard-hoc.js | 6 +- .../shared/components/dashboard-hoc.test.js | 3 +- .../shared/components/dashboard.jsx | 17 + .../shared/components/dashboard.test.js | 59 ++++ .../shared/components/module-menu-hoc.js | 5 +- .../shared/components/module-menu-hoc.test.js | 3 +- .../shared/components/module-menu.jsx | 10 +- .../shared/components/module-menu.test.js | 167 +++++++++- .../components/module-options-dialog.jsx | 4 +- .../shared/components/module-sync-dialog.jsx | 70 ++++ .../shared/components/module-sync-dialog.scss | 94 ++++++ .../components/module-sync-dialog.test.js | 91 ++++++ .../shared/components/module.jsx | 1 + .../__snapshots__/page-module.test.js.snap | 302 +++++++++++++++++- .../shared/components/pages/page-library.scss | 6 +- .../shared/components/pages/page-module.jsx | 296 ++++++++++------- .../shared/components/pages/page-module.scss | 14 +- .../components/pages/page-module.test.js | 183 ++++++++++- .../shared/reducers/dashboard-reducer.js | 23 +- .../shared/reducers/dashboard-reducer.test.js | 58 +++- 42 files changed, 2656 insertions(+), 227 deletions(-) create mode 100644 packages/app/obojobo-repository/shared/components/__snapshots__/module-sync-dialog.test.js.snap create mode 100644 packages/app/obojobo-repository/shared/components/module-sync-dialog.jsx create mode 100644 packages/app/obojobo-repository/shared/components/module-sync-dialog.scss create mode 100644 packages/app/obojobo-repository/shared/components/module-sync-dialog.test.js diff --git a/packages/app/obojobo-express/__tests__/express_current_document.test.js b/packages/app/obojobo-express/__tests__/express_current_document.test.js index 11c07818a5..d6e0e4ab5b 100644 --- a/packages/app/obojobo-express/__tests__/express_current_document.test.js +++ b/packages/app/obojobo-express/__tests__/express_current_document.test.js @@ -3,8 +3,10 @@ const documentFunctions = ['setCurrentDocument', 'requireCurrentDocument', 'rese jest.mock('test_node') jest.mock('../server/models/draft') +jest.mock('obojobo-repository/server/models/drafts_metadata') const DraftDocument = oboRequire('server/models/draft') +const DraftsMetadata = require('obojobo-repository/server/models/drafts_metadata') describe('current document middleware', () => { beforeAll(() => {}) @@ -32,6 +34,7 @@ describe('current document middleware', () => { mockNext.mockClear() mockStatus.mockClear() mockJson.mockClear() + DraftsMetadata.getByDraftIdAndKey.mockClear() }) test('calls next', () => { @@ -168,4 +171,58 @@ describe('current document middleware', () => { done() }) }) + + test('requireDraftWritable rejects when no draftId is available', done => { + expect.assertions(1) + + return mockArgs.req + .requireDraftWritable() + .then(() => { + expect(false).toBe('never_called') + done() + }) + .catch(err => { + expect(err.message).toBe('DraftDocument Required') + done() + }) + }) + + test('requireDraftWritable rejects when corresponding draft is read-only', done => { + expect.assertions(3) + DraftsMetadata.getByDraftIdAndKey.mockResolvedValue(true) + + const { req } = mockArgs + req.params = { + draftId: 1 + } + + return req + .requireDraftWritable() + .then(() => { + expect(false).toBe('never_called') + done() + }) + .catch(err => { + expect(DraftsMetadata.getByDraftIdAndKey).toHaveBeenCalledTimes(1) + expect(DraftsMetadata.getByDraftIdAndKey).toHaveBeenCalledWith(1, 'read_only') + expect(err.message).toBe('Requested document is read-only') + done() + }) + }) + + test('requireDraftWritable resolves when corresponding draft is not read-only', done => { + expect.assertions(2) + DraftsMetadata.getByDraftIdAndKey.mockResolvedValue(false) + + const { req } = mockArgs + req.params = { + draftId: 1 + } + + return req.requireDraftWritable().then(() => { + expect(DraftsMetadata.getByDraftIdAndKey).toHaveBeenCalledTimes(1) + expect(DraftsMetadata.getByDraftIdAndKey).toHaveBeenCalledWith(1, 'read_only') + done() + }) + }) }) diff --git a/packages/app/obojobo-express/__tests__/express_validators.test.js b/packages/app/obojobo-express/__tests__/express_validators.test.js index 1bed32b95a..5f4900b71c 100644 --- a/packages/app/obojobo-express/__tests__/express_validators.test.js +++ b/packages/app/obojobo-express/__tests__/express_validators.test.js @@ -2,6 +2,7 @@ jest.mock('test_node') jest.mock('../server/models/user') +jest.mock('../server/logger') let mockRes let mockReq @@ -10,11 +11,14 @@ let mockUser let mockDocument const Validators = oboRequire('server/express_validators') +const logger = oboRequire('server/logger') describe('current user middleware', () => { beforeAll(() => {}) afterAll(() => {}) beforeEach(() => { + logger.error.mockClear() + mockUser = { id: 1, username: 'mock-user', @@ -163,6 +167,33 @@ describe('current user middleware', () => { }) }) + // requireDraftWritable tests + + test('requireDraftWritable resolves', () => { + mockReq.requireDraftWritable = jest.fn().mockResolvedValue() + + return expect( + Validators.requireDraftWritable(mockReq, mockRes, mockNext) + ).resolves.toBeUndefined() + }) + + test('requireDraftWritable calls next when valid', () => { + mockReq.requireDraftWritable = jest.fn().mockResolvedValue() + + return Validators.requireDraftWritable(mockReq, mockRes, mockNext).then(() => { + expect(mockNext).toHaveBeenCalled() + }) + }) + + test('requireDraftWritable calls missing when invalid', () => { + mockReq.requireDraftWritable = jest.fn().mockRejectedValue() + + return Validators.requireDraftWritable(mockReq, mockRes, mockNext).then(() => { + expect(logger.error).toHaveBeenCalledWith('User tried editing read-only module') + expect(mockRes.missing).toHaveBeenCalled() + }) + }) + // requireDraftId tests test('requireDraftId resolves in body', () => { diff --git a/packages/app/obojobo-express/__tests__/routes/editor.test.js b/packages/app/obojobo-express/__tests__/routes/editor.test.js index 337da50c03..2a62a8bd10 100644 --- a/packages/app/obojobo-express/__tests__/routes/editor.test.js +++ b/packages/app/obojobo-express/__tests__/routes/editor.test.js @@ -32,6 +32,9 @@ jest.mock('../../server/express_current_document', () => (req, res, next) => { req.currentDocument = mockCurrentDocument return Promise.resolve(mockCurrentDocument) } + req.requireDraftWritable = () => { + return Promise.resolve(true) + } next() }) diff --git a/packages/app/obojobo-express/server/express_current_document.js b/packages/app/obojobo-express/server/express_current_document.js index 1a9c7e558b..0048c11ad6 100644 --- a/packages/app/obojobo-express/server/express_current_document.js +++ b/packages/app/obojobo-express/server/express_current_document.js @@ -1,6 +1,19 @@ const DraftDocument = oboRequire('server/models/draft') +const DraftsMetadata = require('obojobo-repository/server/models/drafts_metadata') const logger = oboRequire('server/logger') +const _getDraftId = req => { + // Figure out where the draftId is in this request + if (req.params && req.params.draftId) { + return req.params.draftId + } else if (req.body && req.body.draftId) { + return req.body.draftId + } else if (req.body && req.body.event && req.body.event.draft_id) { + return req.body.event.draft_id + } + return null +} + const setCurrentDocument = (req, draftDocument) => { if (!(draftDocument instanceof DraftDocument)) { throw new Error('Invalid DraftDocument for Current draftDocument') @@ -12,20 +25,26 @@ const resetCurrentDocument = req => { req.currentDocument = null } +const requireDraftWritable = req => { + const draftId = _getDraftId(req) + if (draftId === null) { + logger.warn('No Session or Current DraftDocument?', req.currentDocument) + return Promise.reject(new Error('DraftDocument Required')) + } + + return DraftsMetadata.getByDraftIdAndKey(draftId, 'read_only').then(readOnly => { + if (readOnly) return Promise.reject(new Error('Requested document is read-only')) + return Promise.resolve() + }) +} + const requireCurrentDocument = req => { if (req.currentDocument) { return Promise.resolve(req.currentDocument) } // Figure out where the draftId is in this request - let draftId = null - if (req.params && req.params.draftId) { - draftId = req.params.draftId - } else if (req.body && req.body.draftId) { - draftId = req.body.draftId - } else if (req.body && req.body.event && req.body.event.draft_id) { - draftId = req.body.event.draft_id - } + const draftId = _getDraftId(req) if (draftId === null) { logger.warn('No Session or Current DraftDocument?', req.currentDocument) @@ -42,5 +61,6 @@ module.exports = (req, res, next) => { req.setCurrentDocument = setCurrentDocument.bind(this, req) req.requireCurrentDocument = requireCurrentDocument.bind(this, req) req.resetCurrentDocument = resetCurrentDocument.bind(this, req) + req.requireDraftWritable = requireDraftWritable.bind(this, req) next() } diff --git a/packages/app/obojobo-express/server/express_validators.js b/packages/app/obojobo-express/server/express_validators.js index 9b84e8c613..0baaf0bc96 100644 --- a/packages/app/obojobo-express/server/express_validators.js +++ b/packages/app/obojobo-express/server/express_validators.js @@ -41,6 +41,17 @@ exports.requireCurrentVisit = (req, res, next) => requireAndValidateReqMethod(req, res, next, 'getCurrentVisitFromRequest', 'currentVisit') exports.requireCurrentDocument = (req, res, next) => requireAndValidateReqMethod(req, res, next, 'requireCurrentDocument', 'currentDocument') +exports.requireDraftWritable = (req, res, next) => { + return req + .requireDraftWritable() + .then(() => { + next() + }) + .catch(() => { + logger.error('User tried editing read-only module') + res.missing() + }) +} exports.getCurrentUser = (req, res, next) => { return req.getCurrentUser().then(user => { diff --git a/packages/app/obojobo-express/server/routes/editor.js b/packages/app/obojobo-express/server/routes/editor.js index 65ee36a33d..0bbd8d397b 100644 --- a/packages/app/obojobo-express/server/routes/editor.js +++ b/packages/app/obojobo-express/server/routes/editor.js @@ -9,9 +9,13 @@ const { dbLockDurationMinutes } = generalConfig.editLocks const { assetForEnv, webpackAssetPath } = oboRequire('server/asset_resolver') -const { check, requireCanViewEditor, requireCurrentDocument, checkValidationRules } = oboRequire( - 'server/express_validators' -) +const { + check, + requireCanViewEditor, + requireCurrentDocument, + checkValidationRules, + requireDraftWritable +} = oboRequire('server/express_validators') const allowedUploadTypes = mediaConfig.allowedMimeTypesRegex .split('|') .map(i => `.${i}`) @@ -23,6 +27,7 @@ router .route('/visual/:draftId/:page?') .get([ requireCanViewEditor, + requireDraftWritable, requireCurrentDocument, check('revision_id') .optional() diff --git a/packages/app/obojobo-repository/server/models/draft_permissions.js b/packages/app/obojobo-repository/server/models/draft_permissions.js index 7c631f3994..5bc6c016bc 100644 --- a/packages/app/obojobo-repository/server/models/draft_permissions.js +++ b/packages/app/obojobo-repository/server/models/draft_permissions.js @@ -95,14 +95,28 @@ class DraftPermissions { } // returns a boolean - static async userHasPermissionToCopy(userId, draftId) { + static async userHasPermissionToPreview(user, draftId) { try { const results = await Promise.all([ DraftPermissions.draftIsPublic(draftId), - DraftPermissions.getUserAccessLevelToDraft(userId, draftId) + DraftPermissions.getUserAccessLevelToDraft(user.id, draftId) ]) - return results[0] === true || results[1] !== null + return user.perms.includes('canPreviewDrafts') && (results[0] === true || results[1] !== null) + } catch (error) { + throw logger.logError('Error userHasPermissionToPreview', error) + } + } + + // returns a boolean + static async userHasPermissionToCopy(user, draftId) { + try { + const results = await Promise.all([ + DraftPermissions.draftIsPublic(draftId), + DraftPermissions.getUserAccessLevelToDraft(user.id, draftId) + ]) + + return user.perms.includes('canCreateDrafts') && (results[0] === true || results[1] !== null) } catch (error) { throw logger.logError('Error userHasPermissionToCopy', error) } 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 d4d6e5e07e..ba36a1cd8a 100644 --- a/packages/app/obojobo-repository/server/models/draft_permissions.test.js +++ b/packages/app/obojobo-repository/server/models/draft_permissions.test.js @@ -274,21 +274,101 @@ describe('DraftPermissions Model', () => { }) }) + // userHasPermissionToPreview tests + + test.each` + mockUserPerms | draftIsPublic | getUserAccessLevelToDraft | expected + ${['canPreviewDrafts']} | ${null} | ${'mock-db-result'} | ${true} + ${['canPreviewDrafts']} | ${'mock-db-result'} | ${null} | ${true} + ${['canPreviewDrafts']} | ${null} | ${null} | ${false} + ${['canPreviewDrafts']} | ${'mock-db-result'} | ${'mock-db-result'} | ${true} + ${[]} | ${null} | ${'mock-db-result'} | ${false} + ${[]} | ${'mock-db-result'} | ${null} | ${false} + ${[]} | ${null} | ${null} | ${false} + ${[]} | ${'mock-db-result'} | ${'mock-db-result'} | ${false} + `( + 'userHasPermissionToPreview returns $expected with mockUserPerms $mockUserPerms, db results $draftIsPublic and $getUserAccessLevelToDraft', + ({ mockUserPerms, draftIsPublic, getUserAccessLevelToDraft, expected }) => { + expect.hasAssertions() + + const mockUser = { + id: 'MUID', + perms: mockUserPerms + } + + db.oneOrNone.mockResolvedValueOnce(draftIsPublic) // draftIsPublic call + db.oneOrNone.mockResolvedValueOnce(getUserAccessLevelToDraft) // getUserAccessLevelToDraft call + + return DraftPermissions.userHasPermissionToPreview(mockUser, 'MDID').then(hasPermissions => { + expect(hasPermissions).toBe(expected) + + const [isPublicQuery, isPublicOptions] = db.oneOrNone.mock.calls[0] + expect(isPublicQuery).toContain('SELECT') + expect(isPublicQuery).toContain('FROM repository_map_drafts_to_collections') + expect(isPublicOptions).toEqual({ + draftId: 'MDID', + publicLibCollectionId: '00000000-0000-0000-0000-000000000000' + }) + + const [hasPermsQuery, hasPermsOptions] = db.oneOrNone.mock.calls[1] + expect(hasPermsQuery).toContain('SELECT') + expect(hasPermsQuery).toContain('FROM repository_map_user_to_draft') + expect(hasPermsOptions).toEqual({ + draftId: 'MDID', + userId: 'MUID' + }) + }) + } + ) + + test('userHasPermissionToPreview throws and logs error, getUserAccessLeveltoDraft failure', () => { + expect.hasAssertions() + db.oneOrNone.mockResolvedValueOnce('mock-db-results') // draftIsPublic call + db.oneOrNone.mockRejectedValueOnce(mockError) // getUserAccessLevelToDraft call + + return DraftPermissions.userHasPermissionToPreview('MUID', 'MDID').catch(error => { + expect(logger.logError).toHaveBeenCalledWith('Error userHasPermissionToPreview', mockError) + expect(error).toBe(mockError) + }) + }) + + test('userHasPermissionToPreview throws and logs error, draftIsPublic failure', () => { + expect.hasAssertions() + db.oneOrNone.mockRejectedValueOnce(mockError) // draftIsPublic call + db.oneOrNone.mockResolvedValueOnce('mock-db-results') // getUserAccessLevelToDraft call + + return DraftPermissions.userHasPermissionToPreview('MUID', 'MDID').catch(error => { + expect(logger.logError).toHaveBeenCalledWith('Error userHasPermissionToPreview', mockError) + expect(error).toBe(mockError) + }) + }) + + // userHasPermissionToCopy tests + test.each` - draftIsPublic | getUserAccessLevelToDraft | expected - ${null} | ${'mock-db-result'} | ${true} - ${'mock-db-result'} | ${null} | ${true} - ${null} | ${null} | ${false} - ${'mock-db-result'} | ${'mock-db-result'} | ${true} + mockUserPerms | draftIsPublic | getUserAccessLevelToDraft | expected + ${['canCreateDrafts']} | ${null} | ${'mock-db-result'} | ${true} + ${['canCreateDrafts']} | ${'mock-db-result'} | ${null} | ${true} + ${['canCreateDrafts']} | ${null} | ${null} | ${false} + ${['canCreateDrafts']} | ${'mock-db-result'} | ${'mock-db-result'} | ${true} + ${[]} | ${null} | ${'mock-db-result'} | ${false} + ${[]} | ${'mock-db-result'} | ${null} | ${false} + ${[]} | ${null} | ${null} | ${false} + ${[]} | ${'mock-db-result'} | ${'mock-db-result'} | ${false} `( - 'userHasPermissionToCopy returns $expected with db results $draftIsPublic and $getUserAccessLevelToDraft', - ({ draftIsPublic, getUserAccessLevelToDraft, expected }) => { + 'userHasPermissionToCopy returns $expected with mockUserPerms $mockUserPerms, db results $draftIsPublic and $getUserAccessLevelToDraft', + ({ mockUserPerms, draftIsPublic, getUserAccessLevelToDraft, expected }) => { expect.hasAssertions() + const mockUser = { + id: 'MUID', + perms: mockUserPerms + } + db.oneOrNone.mockResolvedValueOnce(draftIsPublic) // draftIsPublic call db.oneOrNone.mockResolvedValueOnce(getUserAccessLevelToDraft) // getUserAccessLevelToDraft call - return DraftPermissions.userHasPermissionToCopy('MUID', 'MDID').then(hasPermissions => { + return DraftPermissions.userHasPermissionToCopy(mockUser, 'MDID').then(hasPermissions => { expect(hasPermissions).toBe(expected) const [isPublicQuery, isPublicOptions] = db.oneOrNone.mock.calls[0] @@ -310,7 +390,7 @@ describe('DraftPermissions Model', () => { } ) - test('userHasPermissionToCopy throws and logs error', () => { + test('userHasPermissionToCopy throws and logs error, getUserAccessLeveltoDraft failure', () => { expect.hasAssertions() db.oneOrNone.mockResolvedValueOnce('mock-db-results') // draftIsPublic call db.oneOrNone.mockRejectedValueOnce(mockError) // getUserAccessLevelToDraft call @@ -321,7 +401,7 @@ describe('DraftPermissions Model', () => { }) }) - test('userHasPermissionToCopy throws and logs error', () => { + test('userHasPermissionToCopy throws and logs error, draftIsPublic failure', () => { expect.hasAssertions() db.oneOrNone.mockRejectedValueOnce(mockError) // draftIsPublic call db.oneOrNone.mockResolvedValueOnce('mock-db-results') // getUserAccessLevelToDraft call diff --git a/packages/app/obojobo-repository/server/models/draft_summary.js b/packages/app/obojobo-repository/server/models/draft_summary.js index 273b12ab25..ec67cb35b3 100644 --- a/packages/app/obojobo-repository/server/models/draft_summary.js +++ b/packages/app/obojobo-repository/server/models/draft_summary.js @@ -18,11 +18,17 @@ const buildQueryWhere = ( count(drafts_content.id) OVER wnd as revision_count, COALESCE(last_value(drafts_content.content->'content'->>'title') OVER wnd, '') as "title", drafts.user_id AS user_id, + drafts_metadata.value AS read_only, ${selectSQL} 'visual' AS editor FROM drafts JOIN drafts_content ON drafts_content.draft_id = drafts.id + LEFT JOIN drafts_metadata + ON ( + drafts_metadata.draft_id = drafts.id + AND drafts_metadata."key" = 'read_only' + ) ${joinSQL} WHERE drafts.deleted = ${deleted} AND ${whereSQL} @@ -49,7 +55,8 @@ class DraftSummary { id, first_name, last_name, - access_level + access_level, + read_only }) { this.draftId = draft_id this.title = title @@ -61,6 +68,7 @@ class DraftSummary { this.editor = editor this.json = content this.revisionId = id + this.readOnly = read_only if (first_name && last_name) this.userFullName = `${first_name} ${last_name}` if (revision_count) this.revisionCount = Number(revision_count) @@ -84,6 +92,22 @@ class DraftSummary { }) } + static fetchByIdMoreRecentThan(id, targetTime) { + return db + .oneOrNone( + buildQueryWhere(`drafts.id = $[id] + AND drafts_content.created_at > $[targetTime]`), + { id, targetTime } + ) + .then(res => { + if (res) return DraftSummary.resultsToObjects(res) + return null + }) + .catch(error => { + throw logger.logError('DraftSummary fetchByIdMoreRecentThan Error', error) + }) + } + static fetchByUserId(userId) { return DraftSummary.fetchAndJoinWhere( `repository_map_user_to_draft.access_level AS access_level,`, 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 f311179aca..c3edad9281 100644 --- a/packages/app/obojobo-repository/server/models/draft_summary.test.js +++ b/packages/app/obojobo-repository/server/models/draft_summary.test.js @@ -102,11 +102,17 @@ describe('DraftSummary Model', () => { count(drafts_content.id) OVER wnd as revision_count, COALESCE(last_value(drafts_content.content->'content'->>'title') OVER wnd, '') as "title", drafts.user_id AS user_id, + drafts_metadata.value AS read_only, ${selectSQL} 'visual' AS editor FROM drafts JOIN drafts_content ON drafts_content.draft_id = drafts.id + LEFT JOIN drafts_metadata + ON ( + drafts_metadata.draft_id = drafts.id + AND drafts_metadata."key" = 'read_only' + ) ${joinSQL} WHERE drafts.deleted = ${deleted} AND ${whereSQL} @@ -214,6 +220,58 @@ describe('DraftSummary Model', () => { }) }) + test('fetchByIdMoreRecentThan generates the correct query and returns DraftSummary objects correctly', () => { + db.oneOrNone = jest.fn() + db.oneOrNone.mockResolvedValueOnce(mockRawDraftSummaries) + + const mockTargetTime = '1999-01-01 01:00:00.000000+00' + + return DraftSummary.fetchByIdMoreRecentThan('mockDraftId', mockTargetTime).then(summaries => { + const query = queryBuilder(`drafts.id = $[id] + AND drafts_content.created_at > $[targetTime]`) + const [actualQuery, options] = db.oneOrNone.mock.calls[0] + expectQueryToMatch(query, actualQuery) + expect(options).toEqual({ id: 'mockDraftId', targetTime: '1999-01-01 01:00:00.000000+00' }) + expect(summaries.length).toBe(2) + expectIsMockSummary(summaries[0]) + expectIsMockSummary(summaries[1]) + }) + }) + + test('fetchByIdMoreRecentThan generates the correct query and returns for zero results correctly', () => { + db.oneOrNone = jest.fn() + db.oneOrNone.mockResolvedValueOnce() + + const mockTargetTime = '1999-01-01 01:00:00.000000+00' + + return DraftSummary.fetchByIdMoreRecentThan('mockDraftId', mockTargetTime).then(summaries => { + const query = queryBuilder(`drafts.id = $[id] + AND drafts_content.created_at > $[targetTime]`) + const [actualQuery, options] = db.oneOrNone.mock.calls[0] + expectQueryToMatch(query, actualQuery) + expect(options).toEqual({ id: 'mockDraftId', targetTime: '1999-01-01 01:00:00.000000+00' }) + expect(summaries).toBe(null) + }) + }) + + test('fetchByIdMoreRecentThan logs database errors', () => { + expect.hasAssertions() + const mockError = new Error('database error') + logger.logError = jest.fn().mockReturnValueOnce(mockError) + db.oneOrNone.mockRejectedValueOnce(mockError) + + return DraftSummary.fetchByIdMoreRecentThan( + 'mockDraftId', + '1999-01-01 01:00:00.000000+00' + ).catch(err => { + expect(logger.logError).toHaveBeenCalledWith( + 'DraftSummary fetchByIdMoreRecentThan Error', + mockError + ) + expect(err).toBe(mockError) + }) + }) + test('fetchByUserId generates the correct query and returns a DraftSummary object', () => { expect.hasAssertions() diff --git a/packages/app/obojobo-repository/server/models/drafts_metadata.js b/packages/app/obojobo-repository/server/models/drafts_metadata.js index 3d319ee2e1..3b31adc8c0 100644 --- a/packages/app/obojobo-repository/server/models/drafts_metadata.js +++ b/packages/app/obojobo-repository/server/models/drafts_metadata.js @@ -10,6 +10,66 @@ class DraftsMetadata { this.value = value } + static getByDraftId(draftId) { + return db + .manyOrNone( + ` + SELECT * + FROM drafts_metadata + WHERE draft_id = $[draftId] + `, + { draftId } + ) + .then(res => { + if (res) return res.map(r => new DraftsMetadata(r)) + return null + }) + .catch(error => { + logger.logError('DraftMetadata getByDraftId error', error) + throw error + }) + } + + static getByDraftIdAndKey(draftId, key) { + return db + .oneOrNone( + ` + SELECT * + FROM drafts_metadata + WHERE draft_id = $[draftId] AND key = $[key] + `, + { draftId, key } + ) + .then(res => { + if (res) return new DraftsMetadata(res) + return null + }) + .catch(error => { + logger.logError('DraftMetadata getByDraftIdAndKey error', error) + throw error + }) + } + + static getByKeyAndValue(key, value) { + return db + .manyOrNone( + ` + SELECT * + FROM drafts_metadata + WHERE key = $[key] AND value = $[value] + `, + { key, value } + ) + .then(res => { + if (res) return res.map(r => new DraftsMetadata(r)) + return null + }) + .catch(error => { + logger.logError('DraftMetadata getByKeyAndValue error', error) + throw error + }) + } + saveOrCreate() { return db .none( diff --git a/packages/app/obojobo-repository/server/models/drafts_metadata.test.js b/packages/app/obojobo-repository/server/models/drafts_metadata.test.js index 2d52e0f274..ce67bbba87 100644 --- a/packages/app/obojobo-repository/server/models/drafts_metadata.test.js +++ b/packages/app/obojobo-repository/server/models/drafts_metadata.test.js @@ -11,12 +11,26 @@ describe('DraftsMetadata Model', () => { value: 'value' } - const expectMatchesRawMock = draftMetadata => { - expect(draftMetadata.draftId).toBe('mockDraftId') + const mockRawDraftMetadataMultiple = [ + { + draft_id: 'mockDraftId1', + key: 'key', + value: 'value1' + }, + { + draft_id: 'mockDraftId2', + key: 'key', + value: 'value2' + } + ] + + const expectMatchesRawMock = (draftMetadata, number = '') => { + expect(draftMetadata.draftId).toBe(`mockDraftId${number}`) + // these would have dates from the database, but it doesn't really matter here expect(draftMetadata.createdAt).toBeUndefined() expect(draftMetadata.updatedAt).toBeUndefined() expect(draftMetadata.key).toBe('key') - expect(draftMetadata.value).toBe('value') + expect(draftMetadata.value).toBe(`value${number}`) } beforeEach(() => { @@ -76,4 +90,171 @@ describe('DraftsMetadata Model', () => { expect(err).toBe('Error loading DraftsMetadata by query') }) }) + + // getByDraftId tests + + test('getByDraftId generates the correct query and returns DraftsMetadata objects', () => { + expect.hasAssertions() + + db.manyOrNone = jest.fn() + db.manyOrNone.mockResolvedValueOnce(mockRawDraftMetadataMultiple) + + // Trying to match whitespace with the query that's actually running + const query = ` + SELECT * + FROM drafts_metadata + WHERE draft_id = $[draftId] + ` + + return DraftsMetadata.getByDraftId('mockDraftId').then(res => { + expect(db.manyOrNone).toHaveBeenCalledWith(query, { draftId: 'mockDraftId' }) + expect(res.length).toBe(2) + expectMatchesRawMock(res[0], 1) + expectMatchesRawMock(res[1], 2) + }) + }) + + test('getByDraftId generates the correct query and returns for zero results correctly', () => { + expect.hasAssertions() + + db.manyOrNone = jest.fn() + db.manyOrNone.mockResolvedValueOnce() + + // Trying to match whitespace with the query that's actually running + const query = ` + SELECT * + FROM drafts_metadata + WHERE draft_id = $[draftId] + ` + + return DraftsMetadata.getByDraftId('mockDraftId').then(res => { + expect(db.manyOrNone).toHaveBeenCalledWith(query, { draftId: 'mockDraftId' }) + expect(res).toBe(null) + }) + }) + + test('getByDraftId logs database errors', () => { + expect.hasAssertions() + const mockError = new Error('database error') + logger.logError = jest.fn().mockReturnValueOnce(mockError) + db.manyOrNone.mockRejectedValueOnce(mockError) + + return DraftsMetadata.getByDraftId('metaKey', 'metaValue').catch(err => { + expect(logger.logError).toHaveBeenCalledWith('DraftMetadata getByDraftId error', mockError) + expect(err).toBe(mockError) + }) + }) + + // getByDraftIdAndKey tests + + test('getByDraftIdAndKey generates the correct query and returns a DraftsMetadata object', () => { + expect.hasAssertions() + + db.oneOrNone = jest.fn() + db.oneOrNone.mockResolvedValueOnce(mockRawDraftsMetadata) + + // Trying to match whitespace with the query that's actually running + const query = ` + SELECT * + FROM drafts_metadata + WHERE draft_id = $[draftId] AND key = $[key] + ` + + return DraftsMetadata.getByDraftIdAndKey('draftId', 'metaKey').then(res => { + expect(db.oneOrNone).toHaveBeenCalledWith(query, { draftId: 'draftId', key: 'metaKey' }) + expect(res).toBeInstanceOf(DraftsMetadata) + expectMatchesRawMock(res) + }) + }) + + test('getByDraftIdAndKey generates the correct query and returns for zero results correctly', () => { + expect.hasAssertions() + + db.oneOrNone = jest.fn() + db.oneOrNone.mockResolvedValueOnce() + + // Trying to match whitespace with the query that's actually running + const query = ` + SELECT * + FROM drafts_metadata + WHERE draft_id = $[draftId] AND key = $[key] + ` + + return DraftsMetadata.getByDraftIdAndKey('draftId', 'metaKey').then(res => { + expect(db.oneOrNone).toHaveBeenCalledWith(query, { draftId: 'draftId', key: 'metaKey' }) + expect(res).toBe(null) + }) + }) + + test('getByDraftIdAndKey logs database errors', () => { + expect.hasAssertions() + const mockError = new Error('database error') + logger.logError = jest.fn().mockReturnValueOnce(mockError) + db.oneOrNone.mockRejectedValueOnce(mockError) + + return DraftsMetadata.getByDraftIdAndKey('draftId', 'metaKey').catch(err => { + expect(logger.logError).toHaveBeenCalledWith( + 'DraftMetadata getByDraftIdAndKey error', + mockError + ) + expect(err).toBe(mockError) + }) + }) + + // getByKeyAndValue tests + + test('getByKeyAndValue generates the correct query and returns DraftsMetadata objects', () => { + expect.hasAssertions() + + db.manyOrNone = jest.fn() + db.manyOrNone.mockResolvedValueOnce(mockRawDraftMetadataMultiple) + + // Trying to match whitespace with the query that's actually running + const query = ` + SELECT * + FROM drafts_metadata + WHERE key = $[key] AND value = $[value] + ` + + return DraftsMetadata.getByKeyAndValue('metaKey', 'metaValue').then(res => { + expect(db.manyOrNone).toHaveBeenCalledWith(query, { key: 'metaKey', value: 'metaValue' }) + expect(res.length).toBe(2) + expectMatchesRawMock(res[0], 1) + expectMatchesRawMock(res[1], 2) + }) + }) + + test('getByKeyAndValue generates the correct query and returns for zero results correctly', () => { + expect.hasAssertions() + + db.manyOrNone = jest.fn() + db.manyOrNone.mockResolvedValueOnce() + + // Trying to match whitespace with the query that's actually running + const query = ` + SELECT * + FROM drafts_metadata + WHERE key = $[key] AND value = $[value] + ` + + return DraftsMetadata.getByKeyAndValue('metaKey', 'metaValue').then(res => { + expect(db.manyOrNone).toHaveBeenCalledWith(query, { key: 'metaKey', value: 'metaValue' }) + expect(res).toBe(null) + }) + }) + + test('getByKeyAndValue logs database errors', () => { + expect.hasAssertions() + const mockError = new Error('database error') + logger.logError = jest.fn().mockReturnValueOnce(mockError) + db.manyOrNone.mockRejectedValueOnce(mockError) + + return DraftsMetadata.getByKeyAndValue('metaKey', 'metaValue').catch(err => { + expect(logger.logError).toHaveBeenCalledWith( + 'DraftMetadata getByKeyAndValue error', + mockError + ) + expect(err).toBe(mockError) + }) + }) }) diff --git a/packages/app/obojobo-repository/server/routes/api.js b/packages/app/obojobo-repository/server/routes/api.js index f6f55da1e6..20e4795325 100644 --- a/packages/app/obojobo-repository/server/routes/api.js +++ b/packages/app/obojobo-repository/server/routes/api.js @@ -22,7 +22,12 @@ const { fetchAllCollectionsForDraft } = require('../services/collections') const { getUserModuleCount } = require('../services/count') const publicLibCollectionId = require('../../shared/publicLibCollectionId') -const { levelName, levelNumber, FULL } = require('../../../obojobo-express/server/constants') +const { + levelName, + levelNumber, + FULL, + PARTIAL +} = require('../../../obojobo-express/server/constants') // List public drafts router.route('/drafts-public').get((req, res) => { @@ -179,7 +184,9 @@ router const userId = req.currentUser.id const draftId = req.currentDocument.draftId - const canCopy = await DraftPermissions.userHasPermissionToCopy(userId, draftId) + const readOnly = req.body.readOnly + + const canCopy = await DraftPermissions.userHasPermissionToCopy(req.currentUser, draftId) if (!canCopy) { res.notAuthorized('Current user has no permissions to copy this draft') return @@ -191,14 +198,25 @@ router draftObject.content.title = newTitle const newDraft = await Draft.createWithContent(userId, draftObject) - const draftMetadata = new DraftsMetadata({ + const copiedDraftMetadata = new DraftsMetadata({ draft_id: newDraft.id, key: 'copied', value: draftId }) + let readOnlyDraftMetadata = null + + if (readOnly) { + readOnlyDraftMetadata = new DraftsMetadata({ + draft_id: newDraft.id, + key: 'read_only', + value: true + }) + } + await Promise.all([ - draftMetadata.saveOrCreate(), + copiedDraftMetadata.saveOrCreate(), + readOnlyDraftMetadata ? readOnlyDraftMetadata.saveOrCreate() : Promise.resolve(), insertEvent({ actorTime: 'now()', action: 'draft:copy', @@ -220,6 +238,59 @@ router } }) +// check for any changes to the draft's original +router + .route('/drafts/:draftId/sync') + .get([requireCurrentUser, requireCurrentDocument, requireCanCreateDrafts]) + .get((req, res) => { + DraftsMetadata.getByDraftIdAndKey(req.currentDocument.draftId, 'copied') + .then(md => { + return DraftSummary.fetchByIdMoreRecentThan(md.value, md.updatedAt) + }) + .then(mostRecentOriginalRevision => { + res.success(mostRecentOriginalRevision) + }) + .catch(e => { + res.unexpected(e) + }) + }) + +// replace a read-only draft's draft_content with its parent's most recent draft_content +router + .route('/drafts/:draftId/sync') + .patch([requireCurrentUser, requireCurrentDocument, requireCanCreateDrafts]) + .patch((req, res) => { + let copyMeta = null + + DraftsMetadata.getByDraftIdAndKey(req.currentDocument.draftId, 'copied') + .then(async md => { + const userAccess = await DraftPermissions.getUserAccessLevelToDraft( + req.currentUser.id, + req.currentDocument.draftId + ) + + if (!(userAccess === FULL || userAccess === PARTIAL)) { + res.notAuthorized('Current User does not have permission to share this draft') + return + } + + copyMeta = md + // use original draft ID from the copy metadata + const oldDraft = await Draft.fetchById(md.value) + const draftObject = oldDraft.root.toObject() + const newTitle = req.body.title ? req.body.title : draftObject.content.title + ' Copy' + draftObject.content.title = newTitle + return Draft.updateContent(req.currentDocument.draftId, req.currentUser.id, draftObject) + }) + .then(() => { + copyMeta.saveOrCreate() + res.success() + }) + .catch(e => { + res.unexpected(e) + }) + }) + // list a draft's permissions router .route('/drafts/:draftId/permission') diff --git a/packages/app/obojobo-repository/server/routes/api.test.js b/packages/app/obojobo-repository/server/routes/api.test.js index db541d935e..613764c281 100644 --- a/packages/app/obojobo-repository/server/routes/api.test.js +++ b/packages/app/obojobo-repository/server/routes/api.test.js @@ -525,12 +525,13 @@ describe('repository api route', () => { .send({ visitId: 'mockVisitId' }) .then(response => { expect(DraftPermissions.userHasPermissionToCopy).toHaveBeenCalledWith( - mockCurrentUser.id, + mockCurrentUser, mockCurrentDocument.draftId ) expect(Draft.fetchById).toHaveBeenCalledWith(mockCurrentDocument.draftId) expect(mockDraftObject.content.title).toEqual('mockDraftTitle Copy') expect(Draft.createWithContent).toHaveBeenCalledWith(mockCurrentUser.id, mockDraftObject) + expect(DraftsMetadata).toHaveBeenCalledTimes(1) expect(DraftsMetadata).toHaveBeenCalledWith({ draft_id: 'mockNewDraftId', key: 'copied', @@ -589,6 +590,54 @@ describe('repository api route', () => { }) }) + test('post /drafts/:draftId/copy makes the copy read-only if specified', () => { + expect.hasAssertions() + + const mockDraftObject = { + id: 'mockNewDraftId', + content: { + id: 'mockNewDraftContentId', + title: 'mockDraftTitle' + } + } + + const mockDraftRootToObject = jest.fn() + mockDraftRootToObject.mockReturnValueOnce(mockDraftObject) + + const mockDraft = { + root: { + toObject: mockDraftRootToObject + } + } + + DraftPermissions.userHasPermissionToCopy.mockResolvedValueOnce(true) + + Draft.fetchById = jest.fn() + Draft.fetchById.mockResolvedValueOnce(mockDraft) + Draft.createWithContent.mockResolvedValueOnce(mockDraftObject) + + return request(app) + .post('/drafts/mockDraftId/copy') + .send({ visitId: 'mockVisitId', title: 'New Draft Title', readOnly: true }) + .then(() => { + expect(mockDraftObject.content.title).toEqual('New Draft Title') + // everything else is unchanged from above + expect(DraftsMetadata).toHaveBeenCalledTimes(2) + expect(DraftsMetadata).toHaveBeenCalledWith({ + draft_id: 'mockNewDraftId', + key: 'copied', + value: mockCurrentDocument.draftId + }) + expect({ + draft_id: 'mockNewDraftId', + key: 'read_only', + value: true + }) + expect(DraftsMetadata.mock.instances[0].saveOrCreate).toHaveBeenCalledTimes(1) + expect(DraftsMetadata.mock.instances[1].saveOrCreate).toHaveBeenCalledTimes(1) + }) + }) + test('post /drafts/:draftId/copy returns the expected response when user can not copy draft', () => { expect.hasAssertions() @@ -599,7 +648,7 @@ describe('repository api route', () => { .then(response => { expect(DraftPermissions.userHasPermissionToCopy).toHaveBeenCalledTimes(1) expect(DraftPermissions.userHasPermissionToCopy).toHaveBeenCalledWith( - mockCurrentUser.id, + mockCurrentUser, mockCurrentDocument.draftId ) expect(response.statusCode).toBe(401) @@ -616,7 +665,7 @@ describe('repository api route', () => { .then(response => { expect(DraftPermissions.userHasPermissionToCopy).toHaveBeenCalledTimes(1) expect(DraftPermissions.userHasPermissionToCopy).toHaveBeenCalledWith( - mockCurrentUser.id, + mockCurrentUser, mockCurrentDocument.draftId ) expect(response.statusCode).toBe(500) @@ -624,6 +673,160 @@ describe('repository api route', () => { }) }) + test('get /drafts/:draftId/sync calls the correct functions and returns the expected response', () => { + expect.hasAssertions() + + const mockMetadataResponse = { + value: 'mockOriginalDraftId', + updatedAt: '1999-01-01 01:00:00.000000+00' + } + + const mockDraftResponse = { + draftId: 'originalDraftId' + } + + DraftsMetadata.getByDraftIdAndKey.mockResolvedValueOnce(mockMetadataResponse) + DraftSummary.fetchByIdMoreRecentThan.mockResolvedValueOnce(mockDraftResponse) + + return request(app) + .get('/drafts/mockDraftId/sync') + .then(response => { + expect(DraftsMetadata.getByDraftIdAndKey).toHaveBeenCalledWith( + mockCurrentDocument.draftId, + 'copied' + ) + expect(DraftSummary.fetchByIdMoreRecentThan).toHaveBeenCalledWith( + mockMetadataResponse.value, + mockMetadataResponse.updatedAt + ) + expect(response.body).toEqual(mockDraftResponse) + expect(response.statusCode).toBe(200) + }) + }) + + test('get /drafts/:draftId/sync returns unexpected when encountering an error', () => { + expect.hasAssertions() + + const mockError = new Error('not found in db') + DraftsMetadata.getByDraftIdAndKey.mockRejectedValue(mockError) + + return request(app) + .get('/drafts/mockDraftId/sync') + .then(response => { + expect(DraftsMetadata.getByDraftIdAndKey).toHaveBeenCalledWith( + mockCurrentDocument.draftId, + 'copied' + ) + expect(DraftSummary.fetchByIdMoreRecentThan).not.toHaveBeenCalled() + expect(response.statusCode).toBe(500) + }) + }) + + test('patch /drafts/:draftId/sync returns notAuthorized for users with minimal access to the draft', () => { + expect.hasAssertions() + + const mockMetaSaveOrCreate = jest.fn() + DraftsMetadata.getByDraftIdAndKey.mockResolvedValueOnce({ + value: 'mockOriginalDraftId', + updatedAt: '1999-01-01 01:00:00.000000+00', + saveOrCreate: mockMetaSaveOrCreate + }) + + DraftPermissions.getUserAccessLevelToDraft.mockResolvedValueOnce(MINIMAL) + + return request(app) + .patch('/drafts/mockDraftId/sync') + .then(() => { + expect(DraftsMetadata.getByDraftIdAndKey).toHaveBeenCalledWith('mockDraftId', 'copied') + expect(Draft.fetchById).not.toHaveBeenCalled() + expect(Draft.updateContent).not.toHaveBeenCalled() + expect(mockMetaSaveOrCreate).not.toHaveBeenCalled() + }) + }) + + test('patch /drafts/:draftId/sync returns and calls functions correctly for users with partial access to the draft, no optional title', () => { + expect.hasAssertions() + + const mockMetaSaveOrCreate = jest.fn() + DraftsMetadata.getByDraftIdAndKey.mockResolvedValueOnce({ + value: 'mockOriginalDraftId', + updatedAt: '1999-01-01 01:00:00.000000+00', + saveOrCreate: mockMetaSaveOrCreate + }) + + DraftPermissions.getUserAccessLevelToDraft.mockResolvedValueOnce(PARTIAL) + + const mockDraftObject = { + id: 'mockNewDraftId', + content: { + id: 'mockNewDraftContentId', + title: 'mockDraftTitle' + } + } + + const mockDraftRootToObject = jest.fn() + mockDraftRootToObject.mockReturnValueOnce(mockDraftObject) + + const mockDraft = { + root: { + toObject: mockDraftRootToObject + } + } + Draft.fetchById.mockResolvedValueOnce(mockDraft) + + return request(app) + .patch('/drafts/mockDraftId/sync') + .then(() => { + expect(DraftsMetadata.getByDraftIdAndKey).toHaveBeenCalledWith('mockDraftId', 'copied') + expect(Draft.fetchById).toHaveBeenCalledWith('mockOriginalDraftId') + expect(mockDraftObject.content.title).toEqual('mockDraftTitle Copy') + expect(Draft.updateContent).toHaveBeenCalled() + expect(mockMetaSaveOrCreate).toHaveBeenCalled() + }) + }) + + test('patch /drafts/:draftId/sync returns and calls functions correctly for users with partial access to the draft, optional title', () => { + expect.hasAssertions() + + const mockMetaSaveOrCreate = jest.fn() + DraftsMetadata.getByDraftIdAndKey.mockResolvedValueOnce({ + value: 'mockOriginalDraftId', + updatedAt: '1999-01-01 01:00:00.000000+00', + saveOrCreate: mockMetaSaveOrCreate + }) + + DraftPermissions.getUserAccessLevelToDraft.mockResolvedValueOnce(PARTIAL) + + const mockDraftObject = { + id: 'mockNewDraftId', + content: { + id: 'mockNewDraftContentId', + title: 'mockDraftTitle' + } + } + + const mockDraftRootToObject = jest.fn() + mockDraftRootToObject.mockReturnValueOnce(mockDraftObject) + + const mockDraft = { + root: { + toObject: mockDraftRootToObject + } + } + Draft.fetchById.mockResolvedValueOnce(mockDraft) + + return request(app) + .patch('/drafts/mockDraftId/sync') + .send({ title: 'specified title' }) + .then(() => { + expect(DraftsMetadata.getByDraftIdAndKey).toHaveBeenCalledWith('mockDraftId', 'copied') + expect(Draft.fetchById).toHaveBeenCalledWith('mockOriginalDraftId') + expect(mockDraftObject.content.title).toEqual('specified title') + expect(Draft.updateContent).toHaveBeenCalled() + expect(mockMetaSaveOrCreate).toHaveBeenCalled() + }) + }) + test('get /drafts/:draftId/permission returns the expected response', () => { expect.hasAssertions() const userToJSON = jest.fn().mockReturnValue('filtered-user') diff --git a/packages/app/obojobo-repository/server/routes/library.js b/packages/app/obojobo-repository/server/routes/library.js index 5facba2bb3..b19265ffc7 100644 --- a/packages/app/obojobo-repository/server/routes/library.js +++ b/packages/app/obojobo-repository/server/routes/library.js @@ -151,8 +151,13 @@ router owner = await UserModel.fetchById(module.userId) } + const canPreview = await DraftPermissions.userHasPermissionToPreview( + req.currentUser, + module.draftId + ) + const canCopy = await DraftPermissions.userHasPermissionToCopy( - req.currentUser.id, + req.currentUser, module.draftId ) @@ -163,6 +168,7 @@ router // must use webpackAssetPath for all webpack assets to work in dev and production! appCSSUrl: webpackAssetPath('repository.css'), appJsUrl: webpackAssetPath('page-module.js'), + canPreview, canCopy } res.render('pages/page-module-server.jsx', props) diff --git a/packages/app/obojobo-repository/server/routes/library.test.js b/packages/app/obojobo-repository/server/routes/library.test.js index 812f2f7639..110a26b54f 100644 --- a/packages/app/obojobo-repository/server/routes/library.test.js +++ b/packages/app/obojobo-repository/server/routes/library.test.js @@ -214,7 +214,7 @@ describe('repository library route', () => { expect(UserModel.fetchById).toHaveBeenCalledWith(99) expect(DraftPermissions.userHasPermissionToCopy).toHaveBeenCalledTimes(1) expect(DraftPermissions.userHasPermissionToCopy).toHaveBeenCalledWith( - mockCurrentUser.id, + mockCurrentUser, 'mockDraftId' ) expect(response.header['content-type']).toContain('text/html') @@ -301,7 +301,7 @@ describe('repository library route', () => { expect(DraftSummary.fetchById).toHaveBeenCalledWith(publicLibCollectionId) expect(UserModel.fetchById).not.toHaveBeenCalled() expect(DraftPermissions.userHasPermissionToCopy).toHaveBeenCalledWith( - mockCurrentUser.id, + mockCurrentUser, 'mockDraftId' ) expect(response.header['content-type']).toContain('text/html') diff --git a/packages/app/obojobo-repository/shared/actions/dashboard-actions.js b/packages/app/obojobo-repository/shared/actions/dashboard-actions.js index c9071c48eb..6426e5f660 100644 --- a/packages/app/obojobo-repository/shared/actions/dashboard-actions.js +++ b/packages/app/obojobo-repository/shared/actions/dashboard-actions.js @@ -210,6 +210,16 @@ const apiGetModuleLock = async draftId => { return data.value } +const apiGetModuleSyncStatus = draftId => { + const options = { ...defaultOptions() } + return fetch(`/api/drafts/${draftId}/sync`, options).then(res => res.json()) +} + +const apiSyncModuleUpdates = draftId => { + const options = { ...defaultOptions(), method: 'PATCH' } + return fetch(`/api/drafts/${draftId}/sync`, options).then(res => res.json()) +} + // ================== ACTIONS =================== const SHOW_MODULE_PERMISSIONS = 'SHOW_MODULE_PERMISSIONS' @@ -445,6 +455,38 @@ const showModuleMore = module => ({ module }) +const SHOW_MODULE_SYNC = 'SHOW_MODULE_SYNC' +const showModuleSync = module => ({ + type: SHOW_MODULE_SYNC, + meta: { module }, + promise: apiGetModuleSyncStatus(module.draftId) +}) + +const SYNC_MODULE_UPDATES = 'SYNC_MODULE_UPDATES' +const syncModuleUpdates = (draftId, 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: SYNC_MODULE_UPDATES, + promise: apiSyncModuleUpdates(draftId).then(apiModuleGetCall) + } +} + const SHOW_MODULE_MANAGE_COLLECTIONS = 'SHOW_MODULE_MANAGE_COLLECTIONS' const showModuleManageCollections = module => ({ type: SHOW_MODULE_MANAGE_COLLECTIONS, @@ -644,6 +686,8 @@ module.exports = { SELECT_MODULES, DESELECT_MODULES, SHOW_MODULE_MORE, + SHOW_MODULE_SYNC, + SYNC_MODULE_UPDATES, CREATE_NEW_COLLECTION, SHOW_MODULE_MANAGE_COLLECTIONS, LOAD_MODULE_COLLECTIONS, @@ -686,6 +730,8 @@ module.exports = { loadUsersForModule, clearPeopleSearchResults, showModuleMore, + showModuleSync, + syncModuleUpdates, showCollectionManageModules, loadCollectionModules, collectionAddModule, 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 97c8e4460c..5be6a418c1 100644 --- a/packages/app/obojobo-repository/shared/actions/dashboard-actions.test.js +++ b/packages/app/obojobo-repository/shared/actions/dashboard-actions.test.js @@ -1886,23 +1886,18 @@ describe('Dashboard Actions', () => { }) }) - const assertBulkRestoreModulesRunsWithOptions = (secondaryLookupUrl, fetchBody, options) => { + test('bulkRestoreModules returns expected output and calls other functions', () => { global.fetch.mockResolvedValue(standardFetchResponse) - const actionReply = DashboardActions.bulkRestoreModules( - ['mockDraftId1', 'mockDraftId2'], - options - ) + const actionReply = DashboardActions.bulkRestoreModules(['mockDraftId1', 'mockDraftId2']) expect(global.fetch).toHaveBeenCalledTimes(2) expect(global.fetch).toHaveBeenCalledWith('/api/drafts/restore/mockDraftId1', { ...defaultFetchOptions, - method: 'PUT', - body: fetchBody + method: 'PUT' }) expect(global.fetch).toHaveBeenCalledWith('/api/drafts/restore/mockDraftId2', { ...defaultFetchOptions, - method: 'PUT', - body: fetchBody + method: 'PUT' }) global.fetch.mockReset() global.fetch.mockResolvedValueOnce({ @@ -1916,26 +1911,22 @@ describe('Dashboard Actions', () => { return actionReply.promise.then(finalResponse => { expect(standardFetchResponse.json).toHaveBeenCalled() - expect(global.fetch).toHaveBeenCalledWith(secondaryLookupUrl, defaultFetchOptions) + expect(global.fetch).toHaveBeenCalledWith('/api/drafts-deleted', defaultFetchOptions) expect(finalResponse).toEqual({ value: 'mockSecondaryResponse' }) }) - } - test('bulkRestoreModules returns expected output and calls other functions', () => { - return assertBulkRestoreModulesRunsWithOptions('/api/drafts-deleted') }) - const assertGetMyDeletedModulesRunsWithOptions = (secondaryLookupUrl, fetchBody, options) => { + test('apiGetMyDeletedModules returns the expected output', () => { global.fetch.mockResolvedValue(standardFetchResponse) - const actionReply = DashboardActions.getDeletedModules(options) + const actionReply = DashboardActions.getDeletedModules() expect(global.fetch).toHaveBeenCalledTimes(1) expect(global.fetch).toHaveBeenCalledWith('/api/drafts-deleted', { ...defaultFetchOptions, - method: 'GET', - body: fetchBody + method: 'GET' }) global.fetch.mockReset() global.fetch.mockResolvedValueOnce({ @@ -1952,20 +1943,16 @@ describe('Dashboard Actions', () => { expect(global.fetch).not.toHaveBeenCalled() expect(finalResponse).toEqual({ value: 'mockVal' }) }) - } - test('apiGetMyDeletedModules returns the expected output', () => { - return assertGetMyDeletedModulesRunsWithOptions('/api/drafts-deleted') }) - const assertGetMyModulesRunsWithOptions = (secondaryLookupUrl, fetchBody, options) => { + test('apiGetMyModules returns the expected output', () => { global.fetch.mockResolvedValue(standardFetchResponse) - const actionReply = DashboardActions.getModules(options) + const actionReply = DashboardActions.getModules() expect(global.fetch).toHaveBeenCalledTimes(1) expect(global.fetch).toHaveBeenCalledWith('/api/drafts', { ...defaultFetchOptions, - method: 'GET', - body: fetchBody + method: 'GET' }) global.fetch.mockReset() global.fetch.mockResolvedValueOnce({ @@ -1982,8 +1969,74 @@ describe('Dashboard Actions', () => { expect(global.fetch).not.toHaveBeenCalled() expect(finalResponse).toEqual({ value: 'mockVal' }) }) + }) + + test('showModuleSync returns the expected output', () => { + global.fetch.mockResolvedValue(standardFetchResponse) + + const mockModule = { draftId: 'mockDraftId' } + const actionReply = DashboardActions.showModuleSync(mockModule) + + expect(global.fetch).toHaveBeenCalledWith('/api/drafts/mockDraftId/sync', defaultFetchOptions) + expect(actionReply).toEqual({ + type: DashboardActions.SHOW_MODULE_SYNC, + meta: { + module: mockModule + }, + promise: expect.any(Object) + }) + }) + + // three (plus one default) ways of calling syncModuleUpdates + const assertSyncModuleUpdatesRunsWithOptions = (secondaryLookupUrl, options) => { + global.fetch.mockResolvedValueOnce(standardFetchResponse) + const actionReply = DashboardActions.syncModuleUpdates('mockDraftId', options) + + expect(global.fetch).toHaveBeenCalledWith('/api/drafts/mockDraftId/sync', { + ...defaultFetchOptions, + method: 'PATCH' + }) + global.fetch.mockReset() + // two additional API calls are made following the first + global.fetch.mockResolvedValueOnce({ + json: () => ({ value: 'mockSecondVal1' }) + }) + + expect(actionReply).toEqual({ + type: DashboardActions.SYNC_MODULE_UPDATES, + promise: expect.any(Object) + }) + + return actionReply.promise.then(finalResponse => { + expect(standardFetchResponse.json).toHaveBeenCalled() + expect(global.fetch).toHaveBeenCalledWith(secondaryLookupUrl, defaultFetchOptions) + + expect(finalResponse).toEqual({ + value: 'mockSecondVal1' + }) + }) } - test('apiGetMyModules returns the expected output', () => { - return assertGetMyModulesRunsWithOptions('/api/drafts') + //options will contain mode: MODE_COLLECTION and collectionId + test('syncModuleUpdates returns expected output and calls other functions, mode MODE_COLLECTION', () => { + const options = { + mode: MODE_COLLECTION, + collectionId: 'mockCollectionId' + } + return assertSyncModuleUpdatesRunsWithOptions( + '/api/collections/mockCollectionId/modules', + options + ) + }) + //options will contain mode: MODE_RECENT + test('syncModuleUpdates returns expected output and calls other functions, mode MODE_RECENT', () => { + return assertSyncModuleUpdatesRunsWithOptions('/api/recent/drafts', { mode: MODE_RECENT }) + }) + //options will contain mode: MODE_ALL + test('syncModuleUpdates returns expected output and calls other functions, mode MODE_ALL', () => { + return assertSyncModuleUpdatesRunsWithOptions('/api/drafts', { mode: MODE_ALL }) + }) + // no options, default should be equivalent to MODE_ALL + test('syncModuleUpdates returns expected output and calls other functions, default', () => { + return assertSyncModuleUpdatesRunsWithOptions('/api/drafts') }) }) diff --git a/packages/app/obojobo-repository/shared/api-util.js b/packages/app/obojobo-repository/shared/api-util.js index ae43aad975..5e178c44dd 100644 --- a/packages/app/obojobo-repository/shared/api-util.js +++ b/packages/app/obojobo-repository/shared/api-util.js @@ -1,8 +1,8 @@ const API = require('obojobo-document-engine/src/scripts/viewer/util/api') const ViewerAPI = { - copyModule(draftId) { - return API.post(`/api/drafts/${draftId}/copy`).then(result => { + copyModule(draftId, readOnly = false) { + return API.post(`/api/drafts/${draftId}/copy`, { readOnly }).then(result => { if (result.status === 200) { window.location.assign('/dashboard') } else if (result.status === 401) { diff --git a/packages/app/obojobo-repository/shared/api-util.test.js b/packages/app/obojobo-repository/shared/api-util.test.js index 8a41b70ca1..ff0088c6f8 100644 --- a/packages/app/obojobo-repository/shared/api-util.test.js +++ b/packages/app/obojobo-repository/shared/api-util.test.js @@ -39,7 +39,7 @@ describe('repository apiutil', () => { API.post.mockResolvedValueOnce({ status: 200 }) return APIUtil.copyModule('mockDraftId').then(() => { - expect(API.post).toHaveBeenCalledWith('/api/drafts/mockDraftId/copy') + expect(API.post).toHaveBeenCalledWith('/api/drafts/mockDraftId/copy', { readOnly: false }) expect(global.location.assign).toHaveBeenCalledWith('/dashboard') }) }) @@ -50,7 +50,7 @@ describe('repository apiutil', () => { API.post.mockResolvedValueOnce({ status: 401 }) return APIUtil.copyModule('mockDraftId').then(() => { - expect(API.post).toHaveBeenCalledWith('/api/drafts/mockDraftId/copy') + expect(API.post).toHaveBeenCalledWith('/api/drafts/mockDraftId/copy', { readOnly: false }) expect(global.location.assign).not.toHaveBeenCalled() expect(global.alert).toHaveBeenCalledTimes(1) expect(global.alert).toHaveBeenCalledWith('You are not authorized to copy this module') @@ -63,7 +63,7 @@ describe('repository apiutil', () => { API.post.mockResolvedValueOnce({ status: 500 }) return APIUtil.copyModule('mockDraftId').then(() => { - expect(API.post).toHaveBeenCalledWith('/api/drafts/mockDraftId/copy') + expect(API.post).toHaveBeenCalledWith('/api/drafts/mockDraftId/copy', { readOnly: false }) expect(global.location.assign).not.toHaveBeenCalled() expect(global.alert).toHaveBeenCalledTimes(1) expect(global.alert).toHaveBeenCalledWith('Something went wrong while copying') diff --git a/packages/app/obojobo-repository/shared/components/__snapshots__/module-menu.test.js.snap b/packages/app/obojobo-repository/shared/components/__snapshots__/module-menu.test.js.snap index 2c6438472f..3c52a81cbd 100644 --- a/packages/app/obojobo-repository/shared/components/__snapshots__/module-menu.test.js.snap +++ b/packages/app/obojobo-repository/shared/components/__snapshots__/module-menu.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ModuleMenu ModuleMenu renders correctly with "Full" access level 1`] = ` +exports[`ModuleMenu ModuleMenu renders correctly with "Full" access level, no readOnly 1`] = `
@@ -40,7 +40,137 @@ exports[`ModuleMenu ModuleMenu renders correctly with "Full" access level 1`] =
`; -exports[`ModuleMenu ModuleMenu renders correctly with "Minimal" access level 1`] = ` +exports[`ModuleMenu ModuleMenu renders correctly with "Full" access level, readOnly false 1`] = ` +
+
+ + Preview + + + Edit + + +
+ +
+
+`; + +exports[`ModuleMenu ModuleMenu renders correctly with "Full" access level, readOnly true 1`] = ` +
+
+ + Preview + + + +
+ +
+
+`; + +exports[`ModuleMenu ModuleMenu renders correctly with "Minimal" access level, no readOnly 1`] = ` +
+
+ + Preview + +
+ +
+
+`; + +exports[`ModuleMenu ModuleMenu renders correctly with "Minimal" access level, readOnly false 1`] = ` +
+
+ + Preview + +
+ +
+
+`; + +exports[`ModuleMenu ModuleMenu renders correctly with "Minimal" access level, readOnly true 1`] = `
@@ -66,7 +196,7 @@ exports[`ModuleMenu ModuleMenu renders correctly with "Minimal" access level 1`]
`; -exports[`ModuleMenu ModuleMenu renders correctly with "Partial" access level 1`] = ` +exports[`ModuleMenu ModuleMenu renders correctly with "Partial" access level, no readOnly 1`] = `
@@ -100,12 +230,12 @@ exports[`ModuleMenu ModuleMenu renders correctly with "Partial" access level 1`]
`; -exports[`ModuleMenu ModuleMenu renders correctly with className prop 1`] = ` +exports[`ModuleMenu ModuleMenu renders correctly with "Partial" access level, readOnly false 1`] = `
`; -exports[`ModuleMenu ModuleMenu renders correctly with standard expected props 1`] = ` +exports[`ModuleMenu ModuleMenu renders correctly with "Partial" access level, readOnly true 1`] = `
+ + Preview + + +
+ +
+
+`; + +exports[`ModuleMenu ModuleMenu renders correctly with className prop 1`] = ` +
+
Edit + +
+ +
+
+`; + +exports[`ModuleMenu ModuleMenu renders correctly with standard expected props 1`] = ` +
+
+ + Preview +
+
+
+

+ Synchronize Updates +

+
+ This dialog will indicate if any changes have been made to the module this copy was created from. +
+ If there have been any changes, you will be given the option to automatically update this copy to match. +
+ Please note that synchronizing changes may also change this copy's title. +
+
+

+ Checking for updates to this module's original... +

+
+
+
+`; + +exports[`ModuleSyncDialog ModuleSyncDialog renders with provided newest as null 1`] = ` +
+
+
+ +
+
+ Mock Draft Title +
+ +
+
+

+ Synchronize Updates +

+
+ This dialog will indicate if any changes have been made to the module this copy was created from. +
+ If there have been any changes, you will be given the option to automatically update this copy to match. +
+ Please note that synchronizing changes may also change this copy's title. +
+
+

+ No changes found, copy is up-to-date. +

+
+
+
+`; + +exports[`ModuleSyncDialog ModuleSyncDialog renders with provided newest module 1`] = ` +
+
+
+ +
+
+ Mock Draft Title +
+ +
+
+

+ Synchronize Updates +

+
+ This dialog will indicate if any changes have been made to the module this copy was created from. +
+ If there have been any changes, you will be given the option to automatically update this copy to match. +
+ Please note that synchronizing changes may also change this copy's title. +
+
+
+
+ +
+
+ Original Mock Draft Title +
+ + Last updated: + 1999-01-01 1:01 AM + +
+ +
+
+
+
+`; diff --git a/packages/app/obojobo-repository/shared/components/dashboard-hoc.js b/packages/app/obojobo-repository/shared/components/dashboard-hoc.js index a54ef88455..e2e244a1df 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard-hoc.js +++ b/packages/app/obojobo-repository/shared/components/dashboard-hoc.js @@ -36,7 +36,8 @@ const { checkModuleLock, getDeletedModules, getModules, - bulkRestoreModules + bulkRestoreModules, + syncModuleUpdates } = require('../actions/dashboard-actions') const mapStoreStateToProps = state => state const mapActionsToProps = { @@ -75,7 +76,8 @@ const mapActionsToProps = { checkModuleLock, getDeletedModules, getModules, - bulkRestoreModules + bulkRestoreModules, + syncModuleUpdates } module.exports = connect( mapStoreStateToProps, diff --git a/packages/app/obojobo-repository/shared/components/dashboard-hoc.test.js b/packages/app/obojobo-repository/shared/components/dashboard-hoc.test.js index 65bbcf9a53..3365f99698 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard-hoc.test.js +++ b/packages/app/obojobo-repository/shared/components/dashboard-hoc.test.js @@ -59,7 +59,8 @@ describe('Dashboard HOC', () => { bulkRestoreModules: DashboardActions.bulkRestoreModules, changeAccessLevel: DashboardActions.changeAccessLevel, getDeletedModules: DashboardActions.getDeletedModules, - getModules: DashboardActions.getModules + getModules: DashboardActions.getModules, + syncModuleUpdates: DashboardActions.syncModuleUpdates }) expect(mockReduxConnectReturn).toHaveBeenCalledTimes(1) diff --git a/packages/app/obojobo-repository/shared/components/dashboard.jsx b/packages/app/obojobo-repository/shared/components/dashboard.jsx index e8af9ec058..acfd4c58df 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard.jsx +++ b/packages/app/obojobo-repository/shared/components/dashboard.jsx @@ -8,6 +8,7 @@ const RepositoryBanner = require('./repository-banner') const Module = require('./module') const ModulePermissionsDialog = require('./module-permissions-dialog') const ModuleOptionsDialog = require('./module-options-dialog') +const ModuleSyncDialog = require('./module-sync-dialog') const VersionHistoryDialog = require('./version-history-dialog') const Button = require('./button') const MultiButton = require('./multi-button') @@ -42,6 +43,16 @@ const renderOptionsDialog = (props, extension) => ( /> ) +const renderSyncDialog = (props, extension) => ( + +) + const renderPermissionsDialog = (props, extension) => ( { extendedProps.deleteModulePermissions = (draftId, userId) => { props.deleteModulePermissions(draftId, userId, extendedOptions) } + extendedProps.syncModuleUpdates = draftId => props.syncModuleUpdates(draftId, extendedOptions) break default: @@ -124,6 +136,7 @@ const renderModalDialog = props => { extendedProps.deleteModulePermissions = (draftId, userId) => { props.deleteModulePermissions(draftId, userId, extendedOptions) } + extendedProps.syncModuleUpdates = draftId => props.syncModuleUpdates(draftId, extendedOptions) } switch (props.dialog) { @@ -132,6 +145,10 @@ const renderModalDialog = props => { dialog = renderOptionsDialog(props, extendedProps) break + case 'module-sync': + ;(title = 'Module Sync'), (dialog = renderSyncDialog(props, extendedProps)) + break + case 'module-permissions': title = 'Module Access' dialog = renderPermissionsDialog(props, extendedProps) diff --git a/packages/app/obojobo-repository/shared/components/dashboard.test.js b/packages/app/obojobo-repository/shared/components/dashboard.test.js index e70fc015a3..769cefecb8 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard.test.js +++ b/packages/app/obojobo-repository/shared/components/dashboard.test.js @@ -41,6 +41,9 @@ jest.mock('./module-permissions-dialog', () => props => { jest.mock('./module-options-dialog', () => props => { return }) +jest.mock('./module-sync-dialog', () => props => { + return +}) jest.mock('./assessment-score-data-dialog', () => props => { return }) @@ -63,6 +66,7 @@ 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 ModuleSyncDialog from './module-sync-dialog' import VersionHistoryDialog from './version-history-dialog' import AssessmentScoreDataDialog from './assessment-score-data-dialog' @@ -1624,6 +1628,61 @@ describe('Dashboard', () => { expect(component.root.findAllByProps({ className: isLoadingClass }).length).toBe(0) }) + test('renders "Module Sync" dialog and adjusts callbacks for each mode', () => { + dashboardProps.syncModuleUpdates = jest.fn() + dashboardProps.dialog = 'module-sync' + dashboardProps.mode = MODE_RECENT + let component + act(() => { + component = create() + }) + + expectDialogToBeRendered(component, ModuleSyncDialog, 'Module Sync') + const dialogComponent = component.root.findByType(ModuleSyncDialog) + + // ordinarily the draft ID would be provided inside the dialog + dialogComponent.props.syncModuleUpdates('mockDraftId') + expectMethodToBeCalledOnceWith(dashboardProps.syncModuleUpdates, [ + 'mockDraftId', + { mode: MODE_RECENT } + ]) + + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + + dashboardProps.mode = MODE_ALL + act(() => { + component.update() + }) + + dialogComponent.props.syncModuleUpdates('mockDraftId') + expectMethodToBeCalledOnceWith(dashboardProps.syncModuleUpdates, [ + '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.syncModuleUpdates('mockDraftId') + expectMethodToBeCalledOnceWith(dashboardProps.syncModuleUpdates, [ + 'mockDraftId', + { collectionId: 'mockCollectionId', mode: MODE_COLLECTION } + ]) + + dialogComponent.props.onClose() + expectMethodToBeCalledOnceWith(dashboardProps.closeModal) + }) + test('renders "Module Access" dialog and adjusts callbacks for each mode', () => { dashboardProps.dialog = 'module-permissions' dashboardProps.loadUsersForModule = jest.fn() diff --git a/packages/app/obojobo-repository/shared/components/module-menu-hoc.js b/packages/app/obojobo-repository/shared/components/module-menu-hoc.js index f9a642afcb..e1f15ff9bd 100644 --- a/packages/app/obojobo-repository/shared/components/module-menu-hoc.js +++ b/packages/app/obojobo-repository/shared/components/module-menu-hoc.js @@ -3,9 +3,10 @@ const connect = require('react-redux').connect const { showModulePermissions, deleteModule, - showModuleMore + showModuleMore, + showModuleSync } = require('../actions/dashboard-actions') -const mapActionsToProps = { showModulePermissions, deleteModule, showModuleMore } +const mapActionsToProps = { showModulePermissions, deleteModule, showModuleMore, showModuleSync } module.exports = connect( null, mapActionsToProps diff --git a/packages/app/obojobo-repository/shared/components/module-menu-hoc.test.js b/packages/app/obojobo-repository/shared/components/module-menu-hoc.test.js index 1feae2eaf4..a433b5e98c 100644 --- a/packages/app/obojobo-repository/shared/components/module-menu-hoc.test.js +++ b/packages/app/obojobo-repository/shared/components/module-menu-hoc.test.js @@ -19,7 +19,8 @@ describe('ModuleMenu HOC', () => { expect(ReactRedux.connect).toHaveBeenCalledWith(null, { showModulePermissions: DashboardActions.showModulePermissions, deleteModule: DashboardActions.deleteModule, - showModuleMore: DashboardActions.showModuleMore + showModuleMore: DashboardActions.showModuleMore, + showModuleSync: DashboardActions.showModuleSync }) expect(mockReduxConnectReturn).toHaveBeenCalledTimes(1) diff --git a/packages/app/obojobo-repository/shared/components/module-menu.jsx b/packages/app/obojobo-repository/shared/components/module-menu.jsx index a910cd946c..0f2c1c7beb 100644 --- a/packages/app/obojobo-repository/shared/components/module-menu.jsx +++ b/packages/app/obojobo-repository/shared/components/module-menu.jsx @@ -15,17 +15,25 @@ const ModuleMenu = props => { props.showModuleMore(props) } + const onSyncButtonClick = () => { + props.showModuleSync(props) + } + + // accessLevel should always be set - to be safe, don't show an edit button if it isn't return (
Preview - {props.accessLevel !== MINIMAL && ( + {!props.readOnly && props.accessLevel && props.accessLevel !== MINIMAL && ( Edit )} + {props.readOnly && props.accessLevel && props.accessLevel !== MINIMAL && ( + + )} {props.accessLevel === FULL && }
- {props.accessLevel !== MINIMAL && ( + {!props.readOnly && props.accessLevel !== MINIMAL && (
Edit @@ -79,7 +79,7 @@ const ModuleOptionsDialog = props => (
View scores by student.
- {props.accessLevel !== MINIMAL && ( + {!props.readOnly && props.accessLevel !== MINIMAL && (
+
+ ) + } else if (props.newest === null) { + syncableStatusRender = ( +

No changes found, copy is up-to-date.

+ ) + } + + return ( +
+
+ +
+ {props.title} +
+ +
+
+

Synchronize Updates

+
+ This dialog will indicate if any changes have been made to the module this copy was + created from. +
+ If there have been any changes, you will be given the option to automatically update this + copy to match. +
+ Please note that synchronizing changes may also change this copy's title. +
+
{syncableStatusRender}
+
+
+ ) +} + +module.exports = ModuleSyncDialog diff --git a/packages/app/obojobo-repository/shared/components/module-sync-dialog.scss b/packages/app/obojobo-repository/shared/components/module-sync-dialog.scss new file mode 100644 index 0000000000..d99474450f --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/module-sync-dialog.scss @@ -0,0 +1,94 @@ +@import '../../client/css/defaults'; + +.module-sync-dialog { + min-width: 23em; + + .repository--module-icon--image { + width: 1.5em; + height: 1.75em; + display: block; + } + + .module-title { + display: block; + font-size: 0.625em; + font-weight: bold; + color: black; + margin: 0; + max-width: calc(100% - 8.5em); + word-break: break-word; + } + + .top-bar { + text-align: left; + position: relative; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + box-sizing: border-box; + height: 3em; + + .repository--module-icon--image { + position: absolute; + left: 0.75em; + top: 50%; + transform: translate(0, -50%); + } + + .module-title { + position: absolute; + left: 4.5em; + top: 50%; + transform: translate(0, -50%); + max-height: 1.2em; + } + + .close-button { + position: absolute; + right: 0.5em; + top: 50%; + transform: translate(0, -50%); + } + } + + .wrapper { + padding: 0 1.25em; + + .title { + font-weight: 700; + font-size: 1.3em; + margin-bottom: 0.2em; + text-align: center; + } + + .sub-title { + font-size: 0.7em; + margin-bottom: 0.5em; + text-align: left; + } + + .sync-info-wrapper { + background-color: $color-banner-bg; + margin: 1.25em 0; + padding: 1em; + + .sync-info { + display: flex; + align-items: center; + + .module-title { + flex-grow: 1; + margin-left: 1em; + + .last-update-time { + font-size: 0.65em; + } + } + } + + .sync-info-text-only { + display: block; + width: 100%; + text-align: center; + } + } + } +} diff --git a/packages/app/obojobo-repository/shared/components/module-sync-dialog.test.js b/packages/app/obojobo-repository/shared/components/module-sync-dialog.test.js new file mode 100644 index 0000000000..07b53f70d7 --- /dev/null +++ b/packages/app/obojobo-repository/shared/components/module-sync-dialog.test.js @@ -0,0 +1,91 @@ +import React from 'react' +import { create } from 'react-test-renderer' + +import ModuleSyncDialog from './module-sync-dialog' + +describe('ModuleSyncDialog', () => { + let defaultProps + + beforeEach(() => { + jest.resetAllMocks() + + defaultProps = { + draftId: 'mockDraftId', + title: 'Mock Draft Title', + newest: false + } + }) + + test('ModuleSyncDialog renders with default props', () => { + const component = create() + + // 'Checking for updates' text should appear by default + const syncInfoComponent = component.root.findAllByProps({ className: 'sync-info' }) + expect(syncInfoComponent.length).toBe(0) + + const syncInfoTextOnlyComponent = component.root.findAllByProps({ + className: 'sync-info-text-only' + }) + expect(syncInfoTextOnlyComponent.length).toBe(1) + expect(syncInfoTextOnlyComponent[0].props.children).toBe( + "Checking for updates to this module's original..." + ) + + expect(component.toJSON()).toMatchSnapshot() + }) + + test('ModuleSyncDialog renders with provided newest as null', () => { + defaultProps.newest = null + const component = create() + + // 'No updates' text should appear + const syncInfoComponent = component.root.findAllByProps({ className: 'sync-info' }) + expect(syncInfoComponent.length).toBe(0) + + const syncInfoTextOnlyComponent = component.root.findAllByProps({ + className: 'sync-info-text-only' + }) + expect(syncInfoTextOnlyComponent.length).toBe(1) + expect(syncInfoTextOnlyComponent[0].props.children).toBe( + 'No changes found, copy is up-to-date.' + ) + + expect(component.toJSON()).toMatchSnapshot() + }) + + test('ModuleSyncDialog renders with provided newest module', () => { + defaultProps.newest = { + draftId: 'mockOriginalDraftId', + title: 'Original Mock Draft Title', + updatedAt: '1999/01/01 01:01' + } + const component = create() + + // text-only fields should not appear + const syncInfoTextOnlyComponent = component.root.findAllByProps({ + className: 'sync-info-text-only' + }) + expect(syncInfoTextOnlyComponent.length).toBe(0) + + // updated module info should appear + const syncInfoComponent = component.root.findAllByProps({ className: 'sync-info' }) + expect(syncInfoComponent.length).toBe(1) + + expect(component.toJSON()).toMatchSnapshot() + }) + + test('Synchronize button onClick calls syncModuleUpdates', () => { + defaultProps.syncModuleUpdates = jest.fn() + defaultProps.newest = { + draftId: 'mockOriginalDraftId', + title: 'Original Mock Draft Title', + updatedAt: '1999/01/01 01:01' + } + const component = create() + + const syncButton = component.root.findByProps({ className: 'sync-button' }) + syncButton.props.onClick() + + expect(defaultProps.syncModuleUpdates).toHaveBeenCalledWith('mockDraftId') + }) +}) diff --git a/packages/app/obojobo-repository/shared/components/module.jsx b/packages/app/obojobo-repository/shared/components/module.jsx index 497e205b2d..7dfe8a8876 100644 --- a/packages/app/obojobo-repository/shared/components/module.jsx +++ b/packages/app/obojobo-repository/shared/components/module.jsx @@ -72,6 +72,7 @@ const Module = props => { editor={props.editor} title={props.title} accessLevel={props.accessLevel} + readOnly={props.readOnly} /> ) : null} {props.children} diff --git a/packages/app/obojobo-repository/shared/components/pages/__snapshots__/page-module.test.js.snap b/packages/app/obojobo-repository/shared/components/pages/__snapshots__/page-module.test.js.snap index 16da8fad6c..1f4ec5ed33 100644 --- a/packages/app/obojobo-repository/shared/components/pages/__snapshots__/page-module.test.js.snap +++ b/packages/app/obojobo-repository/shared/components/pages/__snapshots__/page-module.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`PageModule renders when given props 1`] = ` +exports[`PageModule renders when given props - current user can copy and preview 1`] = `
+ + Click the "Preview this module" button below to view the module as a learner would! + + + Click one of the buttons below to create a copy of this module (or to cancel the copy process). + + + Note: A read-only copy will not be editable, and can be synchronized with the original from the dashboard. + Preview this module - +
+
+ Module created by + + + firstName + + lastName + + + on + + Jan 1, 1970 + + and updated + + A long time ago + . +
+

+ Use this Module in your Canvas Course +

+

+ This module can be used inside your course as an + + assignment + + or + + module + + . +

+
+ Animated gif showing how to create an Obojobo Assignment in canvas +
+ Creating an Assignment in Canvas +
+
+

+ Create an Assignment in Canvas +

+
    +
  1. + Click Assignments in your course's menu. +
  2. +
  3. + Create a new Assignment +
  4. +
  5. + Set the: +
      +
    • + Assignment Name +
    • +
    • + Points (do not use 0) +
    • +
    • + any other relevant settings +
    • +
    +
  6. +
  7. + Set Submission Type to "External Tool" +
  8. +
  9. + Follow the + + Choosing a Obojobo Module + + instructions below +
  10. +
  11. + Click Select +
  12. +
  13. + Save & Publish +
  14. +
+

+ Create an Ungraded Module in Canvas +

+
    +
  1. + Click Modules in your course's menu. +
  2. +
  3. + Click the "+" in a module +
  4. +
  5. + Type an Assignment Name +
  6. +
  7. + Change the top drop down from "Assignment" to "External Tool". +
  8. +
  9. + Follow the + + Choosing a Obojobo Module + + instructions below +
  10. +
  11. + Click Add Item +
  12. +
  13. + You new module will be named " + mockDraftTitle + (doesn't send scores to grade book)" +
  14. +
  15. + Be sure to + + Publish + + within Canvas when ready +
  16. +
+

+ Choosing a Obojobo Module +

+
    +
  1. + Follow one of the sets of instructions above. +
  2. +
  3. + Click "FIND" next to the input labeled "Enter or find an External Tool URL" +
  4. +
  5. + In the popup that appears, scroll down and select "ObojoboNext Module (gradebook synced)" +
  6. +
  7. + Choose Community Collection +
  8. +
  9. + Search for the module by its title ( + + mockDraftTitle + + ) or its id ( + + mockDraftId + + ) +
  10. +
  11. + Click Embed next to your chosen module +
  12. +
+ +
+`; + +exports[`PageModule renders when given props - current user can not copy or preview 1`] = ` +
+
+ +
+
+
+
+
+ +
+

+ mockDraftTitle +

+
+
+
Module created by diff --git a/packages/app/obojobo-repository/shared/components/pages/page-library.scss b/packages/app/obojobo-repository/shared/components/pages/page-library.scss index a6837178ce..c57baf9011 100644 --- a/packages/app/obojobo-repository/shared/components/pages/page-library.scss +++ b/packages/app/obojobo-repository/shared/components/pages/page-library.scss @@ -1,5 +1,6 @@ /* stylelint-disable */ @import '../../../client/css/defaults'; +@import './page-module.scss'; // reduce a larger font-size set on an ancestor in the library page .repository--nav--links--search { @@ -12,8 +13,3 @@ color: $color-text-placeholder; text-align: center; } - -.copy-button { - display: inline-block; - margin: 0.5em; -} diff --git a/packages/app/obojobo-repository/shared/components/pages/page-module.jsx b/packages/app/obojobo-repository/shared/components/pages/page-module.jsx index 1079b656e3..e78d43cc1b 100644 --- a/packages/app/obojobo-repository/shared/components/pages/page-module.jsx +++ b/packages/app/obojobo-repository/shared/components/pages/page-module.jsx @@ -1,6 +1,7 @@ require('./page-module.scss') const React = require('react') +const { useState } = require('react') const RepositoryNav = require('../repository-nav') const RepositoryBanner = require('../repository-banner') const ModuleImage = require('../module-image') @@ -12,116 +13,197 @@ const relativeTime = require('dayjs/plugin/relativeTime') dayjs.extend(relativeTime) -const PageModule = props => ( -
- - - - - - -
+const PageModule = props => { + const [copyOptionsVisible, setCopyOptionsVisible] = useState(false) + + const copyModule = readOnly => { + APIUtil.copyModule(props.module.draftId, readOnly) + setCopyOptionsVisible(false) + } + + let expositionRender = [] + + let previewButtonRender = null + if (props.canPreview) { + previewButtonRender = ( Preview this module + ) + expositionRender.push( + 'Click the "Preview this module" button below to view the module as a learner would!' + ) + } + + let copyButtonRender = null + if (props.canCopy) { + if (copyOptionsVisible) { + copyButtonRender = ( +
+ + + +
+ ) + } else { + copyButtonRender = ( +
+ +
+ ) + } + expositionRender.push( + 'Click one of the buttons below to create a copy of this module (or to cancel the copy process).' + ) + expositionRender.push( + 'Note: A read-only copy will not be editable, and can be synchronized with the original from the dashboard.' + ) + } + + if (expositionRender.length) { + expositionRender = expositionRender.map((string, index) => ( + + {string} + + )) + } + + return ( +
+ + + + + + +
+ {expositionRender} + {previewButtonRender} + {copyButtonRender} + +
+ Module created by{' '} + + {props.owner.firstName} {props.owner.lastName} + {' '} + on {dayjs(props.module.createdAt).format('MMM D, YYYY')} and updated{' '} + {dayjs(props.module.updatedAt).fromNow()}. +
+ +

Use this Module in your Canvas Course

+

+ This module can be used inside your course as an assignment or module. +

+ +
+ Animated gif showing how to create an Obojobo Assignment in canvas +
Creating an Assignment in Canvas
+
+ +

Create an Assignment in Canvas

+
    +
  1. Click Assignments in your course's menu.
  2. +
  3. Create a new Assignment
  4. +
  5. + Set the: +
      +
    • Assignment Name
    • +
    • Points (do not use 0)
    • +
    • any other relevant settings
    • +
    +
  6. +
  7. Set Submission Type to "External Tool"
  8. +
  9. + Follow the Choosing a Obojobo Module instructions + below +
  10. +
  11. Click Select
  12. +
  13. Save & Publish
  14. +
+ +

Create an Ungraded Module in Canvas

+
    +
  1. Click Modules in your course's menu.
  2. +
  3. Click the "+" in a module
  4. +
  5. Type an Assignment Name
  6. +
  7. + Change the top drop down from "Assignment" to "External Tool". +
  8. +
  9. + Follow the Choosing a Obojobo Module instructions + below +
  10. +
  11. Click Add Item
  12. +
  13. + You new module will be named "{props.module.title} (doesn't send scores to + grade book)" +
  14. +
  15. + Be sure to Publish within Canvas when ready +
  16. +
- - -
- Module created by{' '} - - {props.owner.firstName} {props.owner.lastName} - {' '} - on {dayjs(props.module.createdAt).format('MMM D, YYYY')} and updated{' '} - {dayjs(props.module.updatedAt).fromNow()}. -
- -

Use this Module in your Canvas Course

-

- This module can be used inside your course as an assignment or module. -

- -
- Animated gif showing how to create an Obojobo Assignment in canvas -
Creating an Assignment in Canvas
-
- -

Create an Assignment in Canvas

-
    -
  1. Click Assignments in your course's menu.
  2. -
  3. Create a new Assignment
  4. -
  5. - Set the: -
      -
    • Assignment Name
    • -
    • Points (do not use 0)
    • -
    • any other relevant settings
    • -
    -
  6. -
  7. Set Submission Type to "External Tool"
  8. -
  9. - Follow the Choosing a Obojobo Module instructions - below -
  10. -
  11. Click Select
  12. -
  13. Save & Publish
  14. -
- -

Create an Ungraded Module in Canvas

-
    -
  1. Click Modules in your course's menu.
  2. -
  3. Click the "+" in a module
  4. -
  5. Type an Assignment Name
  6. -
  7. Change the top drop down from "Assignment" to "External Tool".
  8. -
  9. - Follow the Choosing a Obojobo Module instructions - below -
  10. -
  11. Click Add Item
  12. -
  13. - You new module will be named "{props.module.title} (doesn't send scores to grade - book)" -
  14. -
  15. - Be sure to Publish within Canvas when ready -
  16. -
- -

Choosing a Obojobo Module

-
    -
  1. Follow one of the sets of instructions above.
  2. -
  3. - Click "FIND" next to the input labeled "Enter or find an External Tool - URL" -
  4. -
  5. - In the popup that appears, scroll down and select "ObojoboNext Module (gradebook - synced)" -
  6. -
  7. Choose Community Collection
  8. -
  9. - Search for the module by its title ({props.module.title}) or its id ( - {props.module.draftId}) -
  10. -
  11. Click Embed next to your chosen module
  12. -
-
-
-) +

Choosing a Obojobo Module

+
    +
  1. Follow one of the sets of instructions above.
  2. +
  3. + Click "FIND" next to the input labeled "Enter or find an External Tool + URL" +
  4. +
  5. + In the popup that appears, scroll down and select "ObojoboNext Module (gradebook + synced)" +
  6. +
  7. Choose Community Collection
  8. +
  9. + Search for the module by its title ({props.module.title}) or its id ( + {props.module.draftId}) +
  10. +
  11. Click Embed next to your chosen module
  12. +
+
+
+ ) +} module.exports = PageModule diff --git a/packages/app/obojobo-repository/shared/components/pages/page-module.scss b/packages/app/obojobo-repository/shared/components/pages/page-module.scss index c6390c805a..3e4cc028f9 100644 --- a/packages/app/obojobo-repository/shared/components/pages/page-module.scss +++ b/packages/app/obojobo-repository/shared/components/pages/page-module.scss @@ -1,7 +1,17 @@ @import '../layouts/default.scss'; @import '../layouts/footer.scss'; -.copy-button { +.exposition-text { + display: block; + font-size: 0.8em; +} + +.copy-button-container { display: inline-block; - margin: 0.5em; + margin: 0 0.5em; + + .copy-button { + display: inline-block; + margin: 0 0.5em; + } } diff --git a/packages/app/obojobo-repository/shared/components/pages/page-module.test.js b/packages/app/obojobo-repository/shared/components/pages/page-module.test.js index 6e3b1ed2de..57c61106f5 100644 --- a/packages/app/obojobo-repository/shared/components/pages/page-module.test.js +++ b/packages/app/obojobo-repository/shared/components/pages/page-module.test.js @@ -3,10 +3,10 @@ jest.mock('dayjs') import React from 'react' import PageModule from './page-module' -import renderer from 'react-test-renderer' -import { shallow } from 'enzyme' +import { create, act } from 'react-test-renderer' const Button = require('../button') +const ButtonLink = require('../button-link') const APIUtil = require('../../api-util') const dayjs = require('dayjs') @@ -26,6 +26,8 @@ describe('PageModule', () => { }) beforeEach(() => { + jest.clearAllMocks() + mockCurrentUser = { id: 99, avatarUrl: '/path/to/avatar/img', @@ -42,28 +44,189 @@ describe('PageModule', () => { } }) - test('renders when given props', () => { - const component = renderer.create( - + test('renders when given props - current user can copy and preview', () => { + const component = create( + ) + + const previewButton = component.root.findAllByType(ButtonLink) + expect(previewButton.length).toBe(1) + + const copyButtonArea = component.root.findAllByProps({ className: 'copy-button-container' }) + expect(copyButtonArea.length).toBe(1) + + // should only have one button in it + const copyButtons = copyButtonArea[0].findAllByType(Button) + expect(copyButtons.length).toBe(1) + expect(copyButtons[0].props.children).toBe('Copy this module') + const tree = component.toJSON() expect(tree).toMatchSnapshot() }) - test('reacts properly when the copy button is clicked', () => { - const component = shallow( + test('renders when given props - current user can not copy or preview', () => { + const component = create( ) - component.find(Button).simulate('click') + const previewButton = component.root.findAllByType(ButtonLink) + expect(previewButton.length).toBe(0) + + const copyButtonArea = component.root.findAllByProps({ className: 'copy-button-container' }) + expect(copyButtonArea.length).toBe(0) + + const tree = component.toJSON() + expect(tree).toMatchSnapshot() + }) + + test('Shows copy options when the initial copy button is clicked', () => { + let component + act(() => { + component = create( + + ) + }) + + const copyButtonArea = component.root.findByProps({ className: 'copy-button-container' }) + + // should only have one button in it + let copyButtons = copyButtonArea.findAllByType(Button) + expect(copyButtons.length).toBe(1) + expect(copyButtons[0].props.children).toBe('Copy this module') + + act(() => { + copyButtons[0].props.onClick() + }) + + // single button should be replaced with three buttons + copyButtons = copyButtonArea.findAllByType(Button) + expect(copyButtons.length).toBe(3) + + expect(copyButtons[0].props.children).toBe('Normal Copy') + expect(copyButtons[1].props.children).toBe('Read-Only Copy') + expect(copyButtons[2].props.children).toBe('Cancel') + }) + + test('Runs correct functions when the "Normal Copy" button is clicked', () => { + let component + act(() => { + component = create( + + ) + }) + + const copyButtonArea = component.root.findByProps({ className: 'copy-button-container' }) + let copyButtons = copyButtonArea.findAllByType(Button) + + act(() => { + copyButtons[0].props.onClick() + }) + + copyButtons = copyButtonArea.findAllByType(Button) + + act(() => { + copyButtons[0].props.onClick() + }) expect(APIUtil.copyModule).toHaveBeenCalledTimes(1) - expect(APIUtil.copyModule).toHaveBeenCalledWith('mockDraftId') + expect(APIUtil.copyModule).toHaveBeenCalledWith('mockDraftId', false) + }) + + test('Runs correct functions when the "Read-Only Copy" button is clicked', () => { + let component + act(() => { + component = create( + + ) + }) + + const copyButtonArea = component.root.findByProps({ className: 'copy-button-container' }) + let copyButtons = copyButtonArea.findAllByType(Button) + + act(() => { + copyButtons[0].props.onClick() + }) + + copyButtons = copyButtonArea.findAllByType(Button) + + act(() => { + copyButtons[1].props.onClick() + }) + + expect(APIUtil.copyModule).toHaveBeenCalledTimes(1) + expect(APIUtil.copyModule).toHaveBeenCalledWith('mockDraftId', true) + }) + + test('Runs correct functions when the "Cancel" button is clicked', () => { + let component + act(() => { + component = create( + + ) + }) + + const copyButtonArea = component.root.findByProps({ className: 'copy-button-container' }) + + // should only have one button in it + let copyButtons = copyButtonArea.findAllByType(Button) + expect(copyButtons.length).toBe(1) + expect(copyButtons[0].props.children).toBe('Copy this module') + + act(() => { + copyButtons[0].props.onClick() + }) + + // single button should be replaced with three buttons + copyButtons = copyButtonArea.findAllByType(Button) + expect(copyButtons.length).toBe(3) + + expect(copyButtons[0].props.children).toBe('Normal Copy') + expect(copyButtons[1].props.children).toBe('Read-Only Copy') + expect(copyButtons[2].props.children).toBe('Cancel') + + act(() => { + copyButtons[2].props.onClick() + }) + + copyButtons = copyButtonArea.findAllByType(Button) + expect(copyButtons.length).toBe(1) + expect(copyButtons[0].props.children).toBe('Copy this module') }) }) diff --git a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js index de69ff682e..1ddd6c1c15 100644 --- a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js +++ b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.js @@ -18,6 +18,8 @@ const { SELECT_MODULES, DESELECT_MODULES, SHOW_MODULE_MORE, + SHOW_MODULE_SYNC, + SYNC_MODULE_UPDATES, CREATE_NEW_COLLECTION, SHOW_MODULE_MANAGE_COLLECTIONS, LOAD_MODULE_COLLECTIONS, @@ -114,9 +116,10 @@ function DashboardReducer(state, action) { }) }) + case SYNC_MODULE_UPDATES: case DELETE_MODULE: return handle(state, action, { - // close the dialog containing the delete button + // close the dialog start: () => ({ ...state, ...closedDialogState() }), // update myModules and re-apply the filter if one exists success: prevState => { @@ -180,6 +183,24 @@ function DashboardReducer(state, action) { selectedModule: action.module } + case SHOW_MODULE_SYNC: + return handle(state, action, { + // open the dialog while the fetch is in progress + start: () => ({ + ...state, + dialog: 'module-sync', + selectedModule: action.meta.module, + newest: false + }), + // update myModules and re-apply the filter if one exists + success: prevState => { + return { + ...prevState, + newest: action.payload.value + } + } + }) + case SHOW_MODULE_PERMISSIONS: return { ...state, 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 2da368adaf..eb4a636c35 100644 --- a/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.test.js +++ b/packages/app/obojobo-repository/shared/reducers/dashboard-reducer.test.js @@ -25,6 +25,8 @@ const { SELECT_MODULES, DESELECT_MODULES, SHOW_MODULE_MORE, + SHOW_MODULE_SYNC, + SYNC_MODULE_UPDATES, CREATE_NEW_COLLECTION, SHOW_MODULE_MANAGE_COLLECTIONS, LOAD_MODULE_COLLECTIONS, @@ -181,8 +183,9 @@ describe('Dashboard Reducer', () => { expect(newState.collection.title).toEqual(expectedCollectionTitle) } - const runCreateOrDeleteModuleActionTest = testAction => { - const isDeleteModuleTest = testAction === DELETE_MODULE + const runCreateOrSyncUpdatesOrDeleteModuleActionTest = testAction => { + const isSyncOrDeleteModuleTest = + testAction === DELETE_MODULE || testAction === SYNC_MODULE_UPDATES const mockModuleList = [ { draftId: 'mockDraftId', @@ -196,7 +199,7 @@ describe('Dashboard Reducer', () => { const initialState = { dialog: 'module-options', - moduleSearchString: isDeleteModuleTest ? 'B' : '', + moduleSearchString: isSyncOrDeleteModuleTest ? 'B' : '', myModules: [ { draftId: 'oldMockDraftId', @@ -226,7 +229,7 @@ describe('Dashboard Reducer', () => { // DELETE_MODULE changes state on start AND success, CREATE_MODULE just on success let newState - if (isDeleteModuleTest) { + if (isSyncOrDeleteModuleTest) { newState = handleStart(handler) expect(newState.dialog).toBe(null) // no module list changes should have happened yet @@ -237,7 +240,7 @@ describe('Dashboard Reducer', () => { newState = handleSuccess(handler) expect(newState.myModules).not.toEqual(initialState.myModules) expect(newState.myModules).toEqual(mockModuleList) - if (isDeleteModuleTest) { + if (isSyncOrDeleteModuleTest) { expect(newState.filteredModules).not.toEqual(initialState.filteredModules) expect(newState.moduleSearchString).toEqual(initialState.moduleSearchString) expect(newState.filteredModules).toEqual([{ ...mockModuleList[1] }]) @@ -436,12 +439,16 @@ describe('Dashboard Reducer', () => { }) test('CREATE_NEW_MODULE action modifies state correctly', () => { - runCreateOrDeleteModuleActionTest(CREATE_NEW_MODULE) + runCreateOrSyncUpdatesOrDeleteModuleActionTest(CREATE_NEW_MODULE) }) //DELETE_MODULE is more or less the same as CREATE_MODULE, but will auto-filter new modules test('DELETE_MODULE action modifies state correctly', () => { - runCreateOrDeleteModuleActionTest(DELETE_MODULE) + runCreateOrSyncUpdatesOrDeleteModuleActionTest(DELETE_MODULE) + }) + //SYNC_MODULE_UPDATES should be identical to DELETE_MODULE + test('SYNC_MODULE_UPDATE action modifies state correctly', () => { + runCreateOrSyncUpdatesOrDeleteModuleActionTest(SYNC_MODULE_UPDATES) }) test('BULK_DELETE_MODULES action modifies state correctly', () => { @@ -586,6 +593,43 @@ describe('Dashboard Reducer', () => { expect(newState.selectedModule).toEqual(mockSelectedModule) }) + test('SHOW_MODULE_SYNC action modifies state correctly', () => { + const initialState = { + dialog: null, + selectedModule: { + draftId: 'someMockDraftId', + title: 'Some Mock Module Title' + } + } + + const mockModule = { + draftId: 'originalMockDraftId', + title: 'Some New Mock Module Title' + } + const action = { + type: SHOW_MODULE_SYNC, + meta: { + module: { + draftId: 'originalMockDraftId', + title: 'Some New Mock Module Title' + } + }, + payload: { + value: mockModule + } + } + + // asynchronous action - state changes on success + const handler = dashboardReducer(initialState, action) + let newState + + newState = handleStart(handler) + expect(newState.newest).toEqual(false) + + newState = handleSuccess(handler) + expect(newState.newest).toEqual(mockModule) + }) + test('SHOW_MODULE_PERMISSIONS action modifies state correctly', () => { const initialState = { dialog: null, From 49ca2667142e92b4a2646583863c0b5df51b54f7 Mon Sep 17 00:00:00 2001 From: Brandon Stull Date: Thu, 27 Apr 2023 09:42:39 -0400 Subject: [PATCH 2/3] #2005 Added a 'Synchronize' button to the expanded module options menu. --- .../module-options-dialog.test.js.snap | 368 +++++++++++++++++- .../shared/components/dashboard-hoc.js | 2 + .../shared/components/dashboard-hoc.test.js | 1 + .../shared/components/dashboard.jsx | 1 + .../components/module-options-dialog.jsx | 13 + .../components/module-options-dialog.test.js | 112 +++++- 6 files changed, 493 insertions(+), 4 deletions(-) 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 a907aa7bba..e22d35b434 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 renders correctly with Full access level 1`] = ` +exports[`ModuleOptionsDialog renders correctly with Full access level - not read-only 1`] = `
@@ -216,6 +216,204 @@ exports[`ModuleOptionsDialog renders correctly with Full access level 1`] = `
`; +exports[`ModuleOptionsDialog renders correctly with Full access level - read-only 1`] = ` +
+
+
+ +
+
+ Mock Module Title +
+ +
+
+

+ Module Options +

+

+ Your Access Level: + Full +

+
+
+ + Preview + +
+ View with preview controls. +
+
+
+ +
+ Update this module with changes to the original. +
+
+
+ +
+ Add or remove collaborators. +
+
+
+ +
+ View scores by student. +
+
+
+ +
+ Add to or remove from private collections. +
+
+
+ +
+ Download a copy in JSON format. +
+
+
+ +
+ Download a copy in XML format. +
+
+
+ + Public Page + +
+ Visit this modules public page. +
+
+
+ +
+ Say farewell. +
+
+
+ +
+
+`; + exports[`ModuleOptionsDialog renders correctly with Minimal access level 1`] = `
`; -exports[`ModuleOptionsDialog renders correctly with Partial access level 1`] = ` +exports[`ModuleOptionsDialog renders correctly with Partial access level - not read-only 1`] = `
@@ -519,6 +717,172 @@ exports[`ModuleOptionsDialog renders correctly with Partial access level 1`] = `
`; +exports[`ModuleOptionsDialog renders correctly with Partial access level - read-only 1`] = ` +
+
+
+ +
+
+ Mock Module Title +
+ +
+
+

+ Module Options +

+

+ Your Access Level: + Partial +

+
+
+ + Preview + +
+ View with preview controls. +
+
+
+ +
+ Update this module with changes to the original. +
+
+
+ +
+ View scores by student. +
+
+
+ +
+ Add to or remove from private collections. +
+
+
+ +
+ Download a copy in JSON format. +
+
+
+ +
+ Download a copy in XML format. +
+
+
+ + Public Page + +
+ Visit this modules public page. +
+
+
+ +
+
+`; + exports[`ModuleOptionsDialog renders correctly with standard expected props 1`] = `
state @@ -77,6 +78,7 @@ const mapActionsToProps = { getDeletedModules, getModules, bulkRestoreModules, + showModuleSync, syncModuleUpdates } module.exports = connect( diff --git a/packages/app/obojobo-repository/shared/components/dashboard-hoc.test.js b/packages/app/obojobo-repository/shared/components/dashboard-hoc.test.js index 3365f99698..8a62b0d132 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard-hoc.test.js +++ b/packages/app/obojobo-repository/shared/components/dashboard-hoc.test.js @@ -60,6 +60,7 @@ describe('Dashboard HOC', () => { changeAccessLevel: DashboardActions.changeAccessLevel, getDeletedModules: DashboardActions.getDeletedModules, getModules: DashboardActions.getModules, + showModuleSync: DashboardActions.showModuleSync, syncModuleUpdates: DashboardActions.syncModuleUpdates }) diff --git a/packages/app/obojobo-repository/shared/components/dashboard.jsx b/packages/app/obojobo-repository/shared/components/dashboard.jsx index acfd4c58df..8613fb507e 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard.jsx +++ b/packages/app/obojobo-repository/shared/components/dashboard.jsx @@ -40,6 +40,7 @@ const renderOptionsDialog = (props, extension) => ( startLoadingAnimation={props.startLoadingAnimation} stopLoadingAnimation={props.stopLoadingAnimation} showModuleManageCollections={props.showModuleManageCollections} + showModuleSync={props.showModuleSync} /> ) 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 e67851ab7a..a46f5ce9a3 100644 --- a/packages/app/obojobo-repository/shared/components/module-options-dialog.jsx +++ b/packages/app/obojobo-repository/shared/components/module-options-dialog.jsx @@ -51,6 +51,19 @@ const ModuleOptionsDialog = props => (
Write, edit, and update.
)} + {props.readOnly && props.accessLevel !== MINIMAL && ( +
+ +
Update this module with changes to the original.
+
+ )} {props.accessLevel === FULL && (
diff --git a/packages/app/obojobo-repository/shared/components/module-options-dialog.test.js b/packages/app/obojobo-repository/shared/components/module-options-dialog.test.js index c90f109d65..b7ea5dccdf 100644 --- a/packages/app/obojobo-repository/shared/components/module-options-dialog.test.js +++ b/packages/app/obojobo-repository/shared/components/module-options-dialog.test.js @@ -48,30 +48,127 @@ describe('ModuleOptionsDialog', () => { const component = create() + // should only be showing the preview, assessment stats, manage collections and public page buttons + const expectedButtons = ['Preview', 'Assessment Stats', 'Manage Collections', 'Public Page'] + const allButtons = component.root.findAllByProps({ className: 'button-label-group' }) + expect(allButtons.length).toEqual(expectedButtons.length) + allButtons.forEach((b, i) => { + expect(b.children[0].props.children).toEqual(expectedButtons[i]) + }) + expect(mockRepositoryUtils.urlForEditor).not.toHaveBeenCalled() expect(component.toJSON()).toMatchSnapshot() }) - test('renders correctly with Partial access level', () => { + test('renders correctly with Partial access level - not read-only', () => { defaultProps.accessLevel = PARTIAL const component = create() + // should be showing minimal access buttons plus edit and download json/xml buttons + const expectedButtons = [ + 'Preview', + 'Edit', + 'Assessment Stats', + 'Version History', + 'Manage Collections', + 'Download JSON', + 'Download XML', + 'Public Page' + ] + const allButtons = component.root.findAllByProps({ className: 'button-label-group' }) + expect(allButtons.length).toEqual(expectedButtons.length) + allButtons.forEach((b, i) => { + expect(b.children[0].props.children).toEqual(expectedButtons[i]) + }) + expect(mockRepositoryUtils.urlForEditor).toHaveBeenCalledTimes(1) expect(mockRepositoryUtils.urlForEditor).toHaveBeenCalledWith('mockEditorType', 'mockDraftId') expect(component.toJSON()).toMatchSnapshot() }) + test('renders correctly with Partial access level - read-only', () => { + defaultProps.accessLevel = PARTIAL + defaultProps.readOnly = true + const component = create() + + // should be showing minimal access buttons plus synchronize and download json/xml buttons + // but no version history because read-only + const expectedButtons = [ + 'Preview', + 'Synchronize', + 'Assessment Stats', + 'Manage Collections', + 'Download JSON', + 'Download XML', + 'Public Page' + ] + const allButtons = component.root.findAllByProps({ className: 'button-label-group' }) + expect(allButtons.length).toEqual(expectedButtons.length) + allButtons.forEach((b, i) => { + expect(b.children[0].props.children).toEqual(expectedButtons[i]) + }) + + expect(mockRepositoryUtils.urlForEditor).not.toHaveBeenCalled() + + expect(component.toJSON()).toMatchSnapshot() + }) - test('renders correctly with Full access level', () => { + test('renders correctly with Full access level - not read-only', () => { const component = create() + // should be showing partial access buttons plus share and delete buttons + const expectedButtons = [ + 'Preview', + 'Edit', + 'Share', + 'Assessment Stats', + 'Version History', + 'Manage Collections', + 'Download JSON', + 'Download XML', + 'Public Page', + 'Delete' + ] + const allButtons = component.root.findAllByProps({ className: 'button-label-group' }) + // expect(allButtons.length).toEqual(expectedButtons.length) + allButtons.forEach((b, i) => { + expect(b.children[0].props.children).toEqual(expectedButtons[i]) + }) + expect(mockRepositoryUtils.urlForEditor).toHaveBeenCalledTimes(1) expect(mockRepositoryUtils.urlForEditor).toHaveBeenCalledWith('mockEditorType', 'mockDraftId') expect(component.toJSON()).toMatchSnapshot() }) + test('renders correctly with Full access level - read-only', () => { + defaultProps.readOnly = true + const component = create() + + // should be showing partial access buttons plus share and delete buttons + const expectedButtons = [ + 'Preview', + 'Synchronize', + 'Share', + 'Assessment Stats', + 'Manage Collections', + 'Download JSON', + 'Download XML', + 'Public Page', + 'Delete' + ] + const allButtons = component.root.findAllByProps({ className: 'button-label-group' }) + // expect(allButtons.length).toEqual(expectedButtons.length) + allButtons.forEach((b, i) => { + expect(b.children[0].props.children).toEqual(expectedButtons[i]) + // console.log(b.children[0].props.children) + }) + + expect(mockRepositoryUtils.urlForEditor).not.toHaveBeenCalled() + + expect(component.toJSON()).toMatchSnapshot() + }) test('"Share" button calls showModulePermissions', () => { defaultProps.showModulePermissions = jest.fn() @@ -172,4 +269,15 @@ describe('ModuleOptionsDialog', () => { expect(defaultProps.deleteModule).toHaveBeenCalledTimes(1) expect(defaultProps.deleteModule).toHaveBeenCalledWith('mockDraftId') }) + + test('"Synchronize" button brings up synchronize dialog', () => { + defaultProps.readOnly = true + defaultProps.showModuleSync = jest.fn(() => Promise.resolve()) + const component = create() + + component.root.findByProps({ id: 'moduleOptionsDialog-synchronizeButton' }).props.onClick() + + expect(defaultProps.showModuleSync).toHaveBeenCalledTimes(1) + expect(defaultProps.showModuleSync).toHaveBeenCalledWith(defaultProps) + }) }) From a62ca8154a360bad8b977c07e7b78703345eab34 Mon Sep 17 00:00:00 2001 From: Brandon Stull Date: Fri, 21 Jul 2023 16:19:39 -0400 Subject: [PATCH 3/3] #2005 Added a read-only copy button to the module editor file menu. --- .../__tests__/Viewer/util/editor-api.test.js | 32 +++++++++- .../__snapshots__/file-menu.test.js.snap | 2 +- .../components/toolbars/file-menu.test.js | 58 ++++++++++++++++++- .../components/toolbars/file-menu.js | 44 +++++++++++++- .../src/scripts/viewer/util/editor-api.js | 6 +- .../shared/components/dashboard.jsx | 3 +- 6 files changed, 134 insertions(+), 11 deletions(-) diff --git a/packages/app/obojobo-document-engine/__tests__/Viewer/util/editor-api.test.js b/packages/app/obojobo-document-engine/__tests__/Viewer/util/editor-api.test.js index 7c2984fa03..d1acfcb3a1 100644 --- a/packages/app/obojobo-document-engine/__tests__/Viewer/util/editor-api.test.js +++ b/packages/app/obojobo-document-engine/__tests__/Viewer/util/editor-api.test.js @@ -115,10 +115,33 @@ describe('EditorAPI', () => { expect(res).toBe(mockJsonResult) }) - test('copyDraft fetches with the correct args', async () => { + test('copyDraft fetches with the correct args - allow default readOnly', async () => { const res = await EditorAPI.copyDraft('draft-id', 'new-title') - expect(post).toHaveBeenCalledWith('/api/drafts/draft-id/copy', { title: 'new-title' }) + expect(post).toHaveBeenCalledWith('/api/drafts/draft-id/copy', { + title: 'new-title', + readOnly: false + }) + expect(res).toBe(mockJsonResult) + }) + + test('copyDraft fetches with the correct args - readOnly true', async () => { + const res = await EditorAPI.copyDraft('draft-id', 'new-title', true) + + expect(post).toHaveBeenCalledWith('/api/drafts/draft-id/copy', { + title: 'new-title', + readOnly: true + }) + expect(res).toBe(mockJsonResult) + }) + + test('copyDraft fetches with the correct args - readOnly false', async () => { + const res = await EditorAPI.copyDraft('draft-id', 'new-title', false) + + expect(post).toHaveBeenCalledWith('/api/drafts/draft-id/copy', { + title: 'new-title', + readOnly: false + }) expect(res).toBe(mockJsonResult) }) @@ -147,7 +170,10 @@ describe('EditorAPI', () => { expect.hasAssertions() return EditorAPI.copyDraft('mock-draft-id', 'new title').then(result => { - expect(post).toHaveBeenCalledWith('/api/drafts/mock-draft-id/copy', { title: 'new title' }) + expect(post).toHaveBeenCalledWith('/api/drafts/mock-draft-id/copy', { + title: 'new title', + readOnly: false + }) expect(result).toEqual(mockJsonResult) }) }) diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/toolbars/__snapshots__/file-menu.test.js.snap b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/toolbars/__snapshots__/file-menu.test.js.snap index 13f3f96345..7d62fb860d 100644 --- a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/toolbars/__snapshots__/file-menu.test.js.snap +++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/toolbars/__snapshots__/file-menu.test.js.snap @@ -2,5 +2,5 @@ exports[`File Menu File Menu node 1`] = ` "
" +CTRL+S
" `; diff --git a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/toolbars/file-menu.test.js b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/toolbars/file-menu.test.js index d3ba4ddb26..f4de074627 100644 --- a/packages/app/obojobo-document-engine/__tests__/oboeditor/components/toolbars/file-menu.test.js +++ b/packages/app/obojobo-document-engine/__tests__/oboeditor/components/toolbars/file-menu.test.js @@ -119,6 +119,20 @@ describe('File Menu', () => { expect(ModalUtil.show).toHaveBeenCalled() }) + test('FileMenu calls Copy (Read-Only)', () => { + const model = { + title: 'mockTitle' + } + + const component = mount() + + component + .findWhere(n => n.type() === 'button' && n.html().includes('Make a read-only copy...')) + .simulate('click') + + expect(ModalUtil.show).toHaveBeenCalled() + }) + test('FileMenu calls Download', done => { // setup const model = { @@ -243,7 +257,49 @@ describe('File Menu', () => { .instance() .copyModule('new title') .then(() => { - expect(EditorAPI.copyDraft).toHaveBeenCalledWith('mockDraftId', 'new title') + expect(EditorAPI.copyDraft).toHaveBeenCalledWith('mockDraftId', 'new title', false) + }) + }) + + test('copyModuleReadOnly calls copyDraft api', () => { + expect.hasAssertions() + const model = { + flatJSON: () => ({ children: [] }), + children: [ + { + get: () => CONTENT_NODE, + flatJSON: () => ({ children: [] }), + children: { models: [{ get: () => 'mockValue' }] } + }, + { + get: () => ASSESSMENT_NODE + } + ] + } + + const exportToJSON = jest.fn() + + const component = mount( + + ) + + EditorAPI.copyDraft.mockResolvedValueOnce({ + status: 'ok', + value: { + draftId: 'new-copy-draft-id' + } + }) + + return component + .instance() + .copyModuleReadOnly('new title') + .then(() => { + expect(EditorAPI.copyDraft).toHaveBeenCalledWith('mockDraftId', 'new title', true) }) }) diff --git a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/toolbars/file-menu.js b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/toolbars/file-menu.js index ceda023c9a..e913af1451 100644 --- a/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/toolbars/file-menu.js +++ b/packages/app/obojobo-document-engine/src/scripts/oboeditor/components/toolbars/file-menu.js @@ -15,6 +15,7 @@ class FileMenu extends React.PureComponent { constructor(props) { super(props) this.copyModule = this.copyModule.bind(this) + this.copyModuleReadOnly = this.copyModuleReadOnly.bind(this) this.deleteModule = this.deleteModule.bind(this) this.buildFileSelector = this.buildFileSelector.bind(this) } @@ -27,13 +28,37 @@ class FileMenu extends React.PureComponent { }) } - copyModule(newTitle) { + copyModule(newTitle, readOnly = false) { ModalUtil.hide() - return EditorAPI.copyDraft(this.props.draftId, newTitle).then(result => { - window.open(window.location.origin + '/editor/visual/' + result.value.draftId, '_blank') + return EditorAPI.copyDraft(this.props.draftId, newTitle, readOnly).then(result => { + if (readOnly) { + const buttons = [ + { + value: 'OK', + onClick: ModalUtil.hide, + default: true + } + ] + ModalUtil.show( + + A read-only copy of this module has been created. +
+ Read-only copies can not be edited directly. +
+ View the read-only copy in the dashboard to optionally synchronize any edits made to + this module. +
+ ) + } else { + window.open(window.location.origin + '/editor/visual/' + result.value.draftId, '_blank') + } }) } + copyModuleReadOnly(newTitle) { + return this.copyModule(newTitle, true) + } + processFileContent(id, content, type) { EditorAPI.postDraft( id, @@ -101,6 +126,19 @@ class FileMenu extends React.PureComponent { /> ) }, + { + name: 'Make a read-only copy...', + type: 'action', + action: () => + ModalUtil.show( + + ) + }, { name: 'Download', type: 'sub-menu', diff --git a/packages/app/obojobo-document-engine/src/scripts/viewer/util/editor-api.js b/packages/app/obojobo-document-engine/src/scripts/viewer/util/editor-api.js index 3c29b3fb47..cb4645196c 100644 --- a/packages/app/obojobo-document-engine/src/scripts/viewer/util/editor-api.js +++ b/packages/app/obojobo-document-engine/src/scripts/viewer/util/editor-api.js @@ -40,8 +40,10 @@ const EditorAPI = { return API.delete(`/api/drafts/${draftId}`).then(API.processJsonResults) }, - copyDraft(draftId, newTitle) { - return API.post(`/api/drafts/${draftId}/copy`, { title: newTitle }).then(API.processJsonResults) + copyDraft(draftId, newTitle, readOnly = false) { + return API.post(`/api/drafts/${draftId}/copy`, { title: newTitle, readOnly }).then( + API.processJsonResults + ) }, requestEditLock(draftId, contentId) { diff --git a/packages/app/obojobo-repository/shared/components/dashboard.jsx b/packages/app/obojobo-repository/shared/components/dashboard.jsx index 01cdd98e69..be5490103c 100644 --- a/packages/app/obojobo-repository/shared/components/dashboard.jsx +++ b/packages/app/obojobo-repository/shared/components/dashboard.jsx @@ -155,7 +155,8 @@ const renderModalDialog = props => { break case 'module-sync': - ;(title = 'Module Sync'), (dialog = renderSyncDialog(props, extendedProps)) + title = 'Module Sync' + dialog = renderSyncDialog(props, extendedProps) break case 'module-permissions':