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..09661f9e32 100644 --- a/packages/app/obojobo-express/__tests__/express_current_document.test.js +++ b/packages/app/obojobo-express/__tests__/express_current_document.test.js @@ -7,6 +7,8 @@ jest.mock('../server/models/draft') const DraftDocument = oboRequire('server/models/draft') describe('current document middleware', () => { + const standardPartialMockUser = { createdAt: Date.now() } + beforeAll(() => {}) afterAll(() => {}) beforeEach(() => { @@ -105,7 +107,7 @@ describe('current document middleware', () => { }) }) - test('requireCurrentDocument loads the draft document from params when one is avalible', done => { + test('requireCurrentDocument loads a draft document from params when one is available', done => { expect.assertions(2) const { req } = mockArgs @@ -120,7 +122,7 @@ describe('current document middleware', () => { }) }) - test('requireCurrentDocument loads the draft document from body when one is avalible', done => { + test('requireCurrentDocument loads a draft document from body when one is available', done => { expect.assertions(2) const { req } = mockArgs @@ -135,7 +137,7 @@ describe('current document middleware', () => { }) }) - test('requireCurrentDocument loads the draft document from body.event when one is avalible', done => { + test('requireCurrentDocument loads a draft document from body.event when one is available', done => { expect.assertions(2) const { req } = mockArgs @@ -152,6 +154,122 @@ describe('current document middleware', () => { }) }) + test('requireCurrentDocument loads a draft document from params when multiple are available', done => { + expect.assertions(2) + const mockDocument = new DraftDocument({ + draftId: 'mockDraftId1', + contentId: 'mockContentId' + }) + + // The actual result of this will depend on the current user's creation timestamp, + // but for simplicity of this test we'll just mock the result + DraftDocument.fetchById = jest.fn().mockResolvedValue(mockDocument) + + const { req } = mockArgs + req.params = { + draftA: 'mockDraftId1', + draftB: 'mockDraftId2' + } + req.currentUser = standardPartialMockUser + + return req.requireCurrentDocument().then(draftDocument => { + expect(draftDocument.draftId).toBe('mockDraftId1') + expect(draftDocument).toBeInstanceOf(DraftDocument) + done() + }) + }) + + test('requireCurrentDocument loads a draft document from body when multiple are available', done => { + expect.assertions(2) + const mockDocument = new DraftDocument({ + draftId: 'mockDraftId1', + contentId: 'mockContentId' + }) + + // The actual result of this will depend on the current user's creation timestamp, + // but for simplicity of this test we'll just mock the result + DraftDocument.fetchById = jest.fn().mockResolvedValue(mockDocument) + + const { req } = mockArgs + req.body = { + draftA: 'mockDraftId1', + draftB: 'mockDraftId2' + } + req.currentUser = standardPartialMockUser + + return req.requireCurrentDocument().then(draftDocument => { + expect(draftDocument.draftId).toBe('mockDraftId1') + expect(draftDocument).toBeInstanceOf(DraftDocument) + done() + }) + }) + + test('requireCurrentDocument loads a draft document from body.event when multiple are available', done => { + expect.assertions(2) + const mockDocument = new DraftDocument({ + draftId: 'mockDraftId1', + contentId: 'mockContentId' + }) + + // The actual result of this will depend on the current user's creation timestamp, + // but for simplicity of this test we'll just mock the result + DraftDocument.fetchById = jest.fn().mockResolvedValue(mockDocument) + + const { req } = mockArgs + req.body = { + event: { + draftA: 'mockDraftId1', + draftB: 'mockDraftId2' + } + } + req.currentUser = standardPartialMockUser + + return req.requireCurrentDocument().then(draftDocument => { + expect(draftDocument.draftId).toBe('mockDraftId1') + expect(draftDocument).toBeInstanceOf(DraftDocument) + done() + }) + }) + + // This may be subject to change in the future + // Currently, the module chosen in a split-run event is determined by the user's creation date + // Even numbers will go to draftA, odd numbers will go to draftB + test('DraftDocument.fetchById is provided the correct values in split-run requests based on user creation date', done => { + const { req } = mockArgs + + const expectedEvenDraftId = 'mockDraftId1' + const expectedOddDraftId = 'mockDraftId2' + + req.body = { + draftA: expectedEvenDraftId, + draftB: expectedOddDraftId + } + + // Start by forcing a creation date that ends in an odd number + let mockCreationDate = Date.now() + if (mockCreationDate % 2 === 0) mockCreationDate++ + + req.currentUser = { createdAt: mockCreationDate } + + return req + .requireCurrentDocument() + .then(() => { + expect(DraftDocument.fetchById).toBeCalledWith(expectedOddDraftId) + DraftDocument.fetchById.mockClear() + // Change the user's creation date to an even number - should be odd already + mockCreationDate++ + req.currentUser = { createdAt: mockCreationDate } + // Remove the currentDocument so the full process runs again + req.currentDocument = null + // return req.requireCurrentDocument() + }) + .then(req.requireCurrentDocument) + .then(() => { + expect(DraftDocument.fetchById).toBeCalledWith(expectedEvenDraftId) + done() + }) + }) + test('requireCurrentDocument rejects when no DraftDocument is set', done => { expect.assertions(1) diff --git a/packages/app/obojobo-express/__tests__/models/__snapshots__/user.test.js.snap b/packages/app/obojobo-express/__tests__/models/__snapshots__/user.test.js.snap index 88f36509cd..c5fdf4079e 100644 --- a/packages/app/obojobo-express/__tests__/models/__snapshots__/user.test.js.snap +++ b/packages/app/obojobo-express/__tests__/models/__snapshots__/user.test.js.snap @@ -11,7 +11,7 @@ Array [ first_name = $[firstName], last_name = $[lastName], roles = $[roles] - RETURNING id + RETURNING id, created_at ", Object { "accessLevel": undefined, @@ -70,7 +70,7 @@ Array [ first_name = $[firstName], last_name = $[lastName], roles = $[roles] - RETURNING id + RETURNING id, created_at ", Object { "accessLevel": undefined, diff --git a/packages/app/obojobo-express/__tests__/models/user.test.js b/packages/app/obojobo-express/__tests__/models/user.test.js index d138fff5bf..3e4a7a5e41 100644 --- a/packages/app/obojobo-express/__tests__/models/user.test.js +++ b/packages/app/obojobo-express/__tests__/models/user.test.js @@ -8,6 +8,8 @@ const User = require('../../server/models/user') const db = require('../../server/db') const oboEvents = require('../../server/obo_events') +const nowForTests = Date.now() + describe('user model', () => { beforeAll(() => { Date.now = () => 'mockNowDate' @@ -137,7 +139,7 @@ describe('user model', () => { test('creates a new user', () => { expect.hasAssertions() - db.one.mockResolvedValueOnce({ id: 3 }) + db.one.mockResolvedValueOnce({ id: 3, created_at: nowForTests }) const u = new User({ firstName: 'Roger', @@ -149,6 +151,7 @@ describe('user model', () => { return u.saveOrCreate().then(user => { expect(user).toBeInstanceOf(User) expect(user.id).toBe(3) + expect(user.createdAt).toBe(nowForTests) expect(db.one).toHaveBeenCalledTimes(1) expect(db.one.mock.calls[0]).toMatchSnapshot() expect(oboEvents.emit).toHaveBeenCalledTimes(1) @@ -159,7 +162,7 @@ describe('user model', () => { test('saves an existing user', () => { expect.hasAssertions() - db.one.mockResolvedValueOnce({ id: 10 }) + db.one.mockResolvedValueOnce({ id: 10, created_at: nowForTests }) const u = new User({ id: 10, @@ -172,6 +175,7 @@ describe('user model', () => { return u.saveOrCreate().then(user => { expect(user).toBeInstanceOf(User) expect(user.id).toBe(10) + expect(user.createdAt).toBe(nowForTests) expect(db.one).toHaveBeenCalledTimes(1) expect(db.one.mock.calls[0]).toMatchSnapshot() expect(oboEvents.emit).toHaveBeenCalledTimes(1) diff --git a/packages/app/obojobo-express/__tests__/routes/__snapshots__/view-split.test.js.snap b/packages/app/obojobo-express/__tests__/routes/__snapshots__/view-split.test.js.snap new file mode 100644 index 0000000000..4cddec3a86 --- /dev/null +++ b/packages/app/obojobo-express/__tests__/routes/__snapshots__/view-split.test.js.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`view-split route launch visit allows EVENT_BEFORE_NEW_VISIT to alter req 1`] = ` +Array [ + Object { + "action": "visit:create", + "actorTime": "2016-09-22T16:57:14.500Z", + "contentId": undefined, + "draftId": "00000000-0000-0000-0000-000000000000", + "eventVersion": "1.1.0", + "ip": "::ffff:127.0.0.1", + "isPreview": false, + "metadata": Object {}, + "payload": Object { + "deactivatedVisitId": "mocked-deactivated-visit-id", + "visitId": "mocked-visit-id", + }, + "resourceLinkId": 3, + "userId": 4, + "visitId": "mocked-visit-id", + }, +] +`; + +exports[`view-split route launch visit inserts event \`visit:create\` 1`] = ` +Array [ + Object { + "action": "visit:create", + "actorTime": "2016-09-22T16:57:14.500Z", + "contentId": undefined, + "draftId": "00000000-0000-0000-0000-000000000000", + "eventVersion": "1.1.0", + "ip": "::ffff:127.0.0.1", + "isPreview": false, + "metadata": Object {}, + "payload": Object { + "deactivatedVisitId": "mocked-deactivated-visit-id", + "visitId": "mocked-visit-id", + }, + "resourceLinkId": 3, + "userId": 4, + "visitId": "mocked-visit-id", + }, +] +`; diff --git a/packages/app/obojobo-express/__tests__/routes/view-split.test.js b/packages/app/obojobo-express/__tests__/routes/view-split.test.js new file mode 100644 index 0000000000..cd35e23383 --- /dev/null +++ b/packages/app/obojobo-express/__tests__/routes/view-split.test.js @@ -0,0 +1,257 @@ +jest.mock('../../server/models/visit') +jest.mock('../../server/insert_event') +jest.mock('../../server/obo_events') +jest.mock( + '../../server/asset_resolver', + () => ({ + assetForEnv: path => path, + webpackAssetPath: path => path + }), + { virtual: true } +) +// make sure all Date objects use a static date +mockStaticDate() + +jest.mock('../../server/db') +jest.unmock('fs') // need fs working for view rendering +jest.unmock('express') // we'll use supertest + express for this + +// override requireCurrentUser to provide our own +let mockCurrentUser +let mockCurrentVisit +let mockSaveSessionSuccess = true +jest.mock('../../server/express_current_user', () => (req, res, next) => { + req.requireCurrentUser = () => { + req.currentUser = mockCurrentUser + return Promise.resolve(mockCurrentUser) + } + req.saveSessionPromise = () => { + if (mockSaveSessionSuccess) return Promise.resolve() + return Promise.reject() + } + req.getCurrentVisitFromRequest = () => { + req.currentVisit = mockCurrentVisit + return Promise.resolve() + } + next() +}) + +// ovveride requireCurrentDocument to provide our own +let mockCurrentDocument +jest.mock('../../server/express_current_document', () => (req, res, next) => { + req.requireCurrentDocument = () => { + if (!mockCurrentDocument) return Promise.reject() + req.currentDocument = mockCurrentDocument + return Promise.resolve(mockCurrentDocument) + } + res.render = jest + .fn() + .mockImplementation((template, message) => + res.send(`mock template rendered: ${template} with message: ${message}`) + ) + next() +}) + +// ovveride requireCurrentDocument to provide our own +let mockLtiLaunch +jest.mock('../../server/express_lti_launch', () => ({ + assignment: (req, res, next) => { + req.lti = { body: mockLtiLaunch } + req.oboLti = { + launchId: 'mock-launch-id', + body: mockLtiLaunch + } + next() + } +})) + +// setup express server +const request = require('supertest') +const express = require('express') +const app = express() +app.set('view engine', 'ejs') +app.set('views', __dirname + '../../../server/views/') +app.use(oboRequire('server/express_current_user')) +app.use(oboRequire('server/express_current_document')) +app.use('/', oboRequire('server/express_response_decorator')) +app.use('/', oboRequire('server/routes/view-split')) + +describe('view-split route', () => { + const insertEvent = oboRequire('server/insert_event') + const VisitModel = oboRequire('server/models/visit') + const oboEvents = oboRequire('server/obo_events') + + const mockLtiObj = { + resource_link_id: 3, + draftA: 'mockDraftId1', + draftB: 'mockDraftId2' + } + + beforeAll(() => {}) + afterAll(() => {}) + beforeEach(() => { + mockCurrentVisit = { is_preview: false } + mockCurrentUser = { id: 4, hasPermission: perm => perm === 'canViewAsStudent' } + insertEvent.mockReset() + VisitModel.createVisit.mockReset() + VisitModel.fetchById.mockResolvedValue(mockCurrentVisit) + }) + afterEach(() => {}) + + test('launch visit requires current user in form requests', () => { + expect.assertions(3) + mockCurrentUser = null + return request(app) + .post(`/`) + .type('application/x-www-form-urlencoded') + .then(response => { + expect(response.header['content-type']).toContain('text/html') + expect(response.statusCode).toBe(401) + expect(response.text).toBe('Not Authorized') + }) + }) + + test('launch visit requires current user in json requests', () => { + expect.assertions(5) + mockCurrentUser = null + return request(app) + .post(`/`) + .type('application/json') + .set('Accept', 'application/json') + .then(response => { + expect(response.header['content-type']).toContain('application/json') + expect(response.statusCode).toBe(401) + expect(response.body).toHaveProperty('status', 'error') + expect(response.body).toHaveProperty('value') + expect(response.body.value).toHaveProperty('type', 'notAuthorized') + }) + }) + + test('launch visit requires a currentDocument', () => { + expect.assertions(2) + mockCurrentDocument = null + + return request(app) + .post(`/`) + .type('application/x-www-form-urlencoded') + .then(response => { + expect(response.header['content-type']).toContain('text/html') + expect(response.statusCode).toBe(404) + }) + }) + + test('launch visit redirects to view for students', () => { + expect.assertions(3) + + VisitModel.createVisit.mockResolvedValueOnce({ + visitId: 'mocked-visit-id', + deactivatedVisitId: 'mocked-deactivated-visit-id' + }) + + mockCurrentDocument = { draftId: validUUID() } + mockLtiLaunch = mockLtiObj + + return request(app) + .post(`/`) + .then(response => { + expect(response.header['content-type']).toContain('text/plain') + expect(response.statusCode).toBe(302) + expect(response.text).toBe( + 'Found. Redirecting to /view/' + validUUID() + '/visit/mocked-visit-id' + ) + }) + }) + + test('launch visit emits a SPLIT_RUN_PREVIEW event', () => { + expect.assertions(2) + mockCurrentUser.hasPermission = () => false + + mockCurrentDocument = { draftId: validUUID() } + mockLtiLaunch = mockLtiObj + + // For some reason this test will hang unless the response is handled + // We can spoof the end result of the oboEvents.emit call to move things along + oboEvents.emit.mockImplementation(({ res }) => { + res.render() + }) + + return request(app) + .post(`/`) + .then(() => { + expect(oboEvents.emit).toHaveBeenCalledWith( + 'SPLIT_RUN_PREVIEW', + expect.objectContaining({ moduleOptionIds: ['mockDraftId1', 'mockDraftId2'] }) + ) + expect(VisitModel.createVisit).not.toHaveBeenCalled() + + oboEvents.emit.mockReset() + }) + }) + + test('launch visit inserts event `visit:create`', () => { + expect.assertions(4) + VisitModel.createVisit.mockResolvedValueOnce({ + visitId: 'mocked-visit-id', + deactivatedVisitId: 'mocked-deactivated-visit-id' + }) + + mockCurrentDocument = { draftId: validUUID() } + mockLtiLaunch = mockLtiObj + + return request(app) + .post(`/`) + .then(response => { + expect(response.header['content-type']).toContain('text/plain') + expect(response.statusCode).toBe(302) + expect(insertEvent).toHaveBeenCalledTimes(1) + expect(insertEvent.mock.calls[0]).toMatchSnapshot() + }) + }) + + test('launch visit allows EVENT_BEFORE_NEW_VISIT to alter req', () => { + expect.assertions(4) + VisitModel.createVisit.mockResolvedValueOnce({ + visitId: 'mocked-visit-id', + deactivatedVisitId: 'mocked-deactivated-visit-id' + }) + + // use event listener to alter req + oboEvents.emit.mockImplementation((event, options) => { + options.req.visitOptions = {} + }) + + mockCurrentDocument = { draftId: validUUID() } + mockLtiLaunch = mockLtiObj + + return request(app) + .post(`/`) + .then(response => { + expect(response.header['content-type']).toContain('text/plain') + expect(response.statusCode).toBe(302) + expect(insertEvent).toHaveBeenCalledTimes(1) + expect(insertEvent.mock.calls[0]).toMatchSnapshot() + }) + }) + + test('launch visit doesnt redirect with session errors', () => { + expect.assertions(3) + + mockSaveSessionSuccess = false + + VisitModel.createVisit.mockResolvedValueOnce({ + visitId: 'mocked-visit-id', + deactivatedVisitId: 'mocked-deactivated-visit-id' + }) + + mockCurrentDocument = { draftId: validUUID() } + mockLtiLaunch = mockLtiObj + + return request(app) + .post(`/`) + .then(response => { + expect(response.header['content-type']).toContain('text/html') + expect(VisitModel.createVisit).toHaveBeenCalledTimes(1) + expect(response.statusCode).toBe(500) + }) + }) +}) diff --git a/packages/app/obojobo-express/server/express_current_document.js b/packages/app/obojobo-express/server/express_current_document.js index 1a9c7e558b..7f8d296d10 100644 --- a/packages/app/obojobo-express/server/express_current_document.js +++ b/packages/app/obojobo-express/server/express_current_document.js @@ -12,6 +12,8 @@ const resetCurrentDocument = req => { req.currentDocument = null } +const chooseSplitRunDraft = (user, draftA, draftB) => (user.createdAt % 2 === 0 ? draftA : draftB) + const requireCurrentDocument = req => { if (req.currentDocument) { return Promise.resolve(req.currentDocument) @@ -19,7 +21,18 @@ const requireCurrentDocument = req => { // Figure out where the draftId is in this request let draftId = null - if (req.params && req.params.draftId) { + + // Check first if this is a split-run request, in which case + // there will be two drafts indicated as options and we need + // to choose one + // Otherwise this will be a single-module request + if (req.params && req.params.draftA && req.params.draftB) { + draftId = chooseSplitRunDraft(req.currentUser, req.params.draftA, req.params.draftB) + } else if (req.body && req.body.draftA && req.body.draftB) { + draftId = chooseSplitRunDraft(req.currentUser, req.body.draftA, req.body.draftB) + } else if (req.body && req.body.event && req.body.event.draftA && req.body.event.draftB) { + draftId = chooseSplitRunDraft(req.currentUser, req.body.event.draftA, req.body.event.draftB) + } else if (req.params && req.params.draftId) { draftId = req.params.draftId } else if (req.body && req.body.draftId) { draftId = req.body.draftId diff --git a/packages/app/obojobo-express/server/models/user.js b/packages/app/obojobo-express/server/models/user.js index e2c420667a..ab9a6d7bfd 100644 --- a/packages/app/obojobo-express/server/models/user.js +++ b/packages/app/obojobo-express/server/models/user.js @@ -114,7 +114,7 @@ class User { first_name = $[firstName], last_name = $[lastName], roles = $[roles] - RETURNING id + RETURNING id, created_at `, this ) @@ -125,6 +125,8 @@ class User { // populate my id from the result this.id = insertUserResult.id } + // populate createdAt time from the result + this.createdAt = insertUserResult.created_at oboEvents.emit(eventName, this) return this }) diff --git a/packages/app/obojobo-express/server/obo_express.js b/packages/app/obojobo-express/server/obo_express.js index 23efca6b93..20b84ef6ac 100644 --- a/packages/app/obojobo-express/server/obo_express.js +++ b/packages/app/obojobo-express/server/obo_express.js @@ -34,6 +34,7 @@ app.on('mount', app => { // =========== ROUTING & CONTROLLERS =========== app.use('/preview', oboRequire('server/routes/preview')) app.use('/view', oboRequire('server/routes/viewer')) + app.use('/view-split', oboRequire('server/routes/view-split')) app.use('/editor', oboRequire('server/routes/editor')) app.use('/lti', oboRequire('server/routes/lti')) app.use('/api/drafts', oboRequire('server/routes/api/drafts')) diff --git a/packages/app/obojobo-express/server/obo_express_dev.js b/packages/app/obojobo-express/server/obo_express_dev.js index c608054d65..fb96dc967e 100644 --- a/packages/app/obojobo-express/server/obo_express_dev.js +++ b/packages/app/obojobo-express/server/obo_express_dev.js @@ -83,6 +83,7 @@ const renderLtiLaunch = (paramsIn, method, endpoint, res) => { oauth_version: '1.0' } const params = { ...paramsIn, ...oauthParams } + const hmac_sha1 = sig.generate(method, endpoint, params, oauthSecret, '', { encodeSignature: false }) @@ -132,6 +133,17 @@ module.exports = app => { .map(draft => ``) .join('') + // in support of quickly testing the split-run feature + // create a copy of the draft options, but auto-select the last one so it's different from the first option + const draftOptions2 = drafts + .map((draft, index) => { + if (index === drafts.length - 1) { + return `` + } + return `` + }) + .join('') + let userSelectRender = '

No users found. Create a student or instructor with the buttons above.

' if (userOptions && userOptions.length) { @@ -162,142 +174,174 @@ module.exports = app => { -

Obojobo Next Express Dev Utils

-

User Management Tools

- -

LTI Tools

- -

Build Tools

- -

Iframe for simulating assignment selection overlay

- +
+

Obojobo Next Express Dev Utils

+

User Management Tools

+ +

LTI Tools

+ +

Build Tools

+ +
+
+

Iframe for simulating assignment selection overlay

+ +
`) }) @@ -382,6 +426,35 @@ module.exports = app => { }) }) + // builds a valid split-run document view lti launch and submits it + app.get('/lti/dev/launch/view-split/', (req, res) => { + User.fetchById(req.query.user_id).then(user => { + const resource_link_id = req.query.resource_link_id || defaultResourceLinkId + const draftId1 = req.query.draft_id_1 || '00000000-0000-0000-0000-000000000000' + const draftId2 = req.query.draft_id_2 || '00000000-0000-0000-0000-000000000000' + + if (draftId1 === draftId2) return + + const params = { + lis_outcome_service_url: 'https://example.fake/outcomes/fake', + lti_message_type: 'basic-lti-launch-request', + lti_version: 'LTI-1p0', + resource_link_id, + draftA: draftId1, + draftB: draftId2, + score_import: req.query.import_enabled === 'on' ? 'true' : 'false' + } + const person = spoofLTIUser(user) + + renderLtiLaunch( + { ...ltiContext, ...person, ...params }, + 'POST', + `${baseUrl(req)}/view-split`, + res + ) + }) + }) + // builds a valid resourse selection lti launch and submits it app.get('/lti/dev/launch/resource_selection', (req, res) => { User.fetchById(req.query.user_id).then(user => { diff --git a/packages/app/obojobo-express/server/routes/view-split.js b/packages/app/obojobo-express/server/routes/view-split.js new file mode 100644 index 0000000000..434714dc01 --- /dev/null +++ b/packages/app/obojobo-express/server/routes/view-split.js @@ -0,0 +1,66 @@ +const express = require('express') +const router = express.Router() +const Visit = oboRequire('server/models/visit') +const insertEvent = oboRequire('server/insert_event') +const oboEvents = require('../obo_events') +const ltiLaunch = oboRequire('server/express_lti_launch') +const { checkValidationRules, requireCurrentDocument, requireCurrentUser } = oboRequire( + 'server/express_validators' +) + +// launch lti view of draft - redirects to visit route +// mounted as /view/:draftId/:page +router + .route('/') + .post([ltiLaunch.assignment, requireCurrentUser, requireCurrentDocument, checkValidationRules]) + .post((req, res, next) => { + // Send instructors to a page listing the modules students will be split between + if (!req.currentUser.hasPermission('canViewAsStudent')) { + // Currently only two options are allowed, but maybe in the future we can allow more? + const moduleOptionIds = [req.oboLti.body.draftA, req.oboLti.body.draftB] + + return oboEvents.emit('SPLIT_RUN_PREVIEW', { req, res, next, moduleOptionIds }) + } + + let createdVisitId + // fire an event and allow nodes to alter node visit + // Warning - I don't thinks async can work in any listeners + oboEvents.emit(Visit.EVENT_BEFORE_NEW_VISIT, { req }) + + const nodeVisitOptions = req.visitOptions ? req.visitOptions : {} + + return Visit.createVisit( + req.currentUser.id, + req.currentDocument.draftId, + req.oboLti.body.resource_link_id, + req.oboLti.launchId, + nodeVisitOptions + ) + .then(({ visitId, deactivatedVisitId }) => { + createdVisitId = visitId + return insertEvent({ + action: 'visit:create', + actorTime: new Date().toISOString(), + userId: req.currentUser.id, + ip: req.connection.remoteAddress, + metadata: {}, // we should probably put something in here indicating that this was an A/B testing launch, dunno what would be useful though + draftId: req.currentDocument.draftId, + contentId: req.currentDocument.contentId, + isPreview: false, + payload: { + visitId, + deactivatedVisitId + }, + resourceLinkId: req.oboLti.body.resource_link_id, + eventVersion: '1.1.0', + visitId + }) + }) + .then(req.saveSessionPromise) + .then(() => { + res.redirect(`/view/${req.currentDocument.draftId}/visit/${createdVisitId}`) + }) + .catch(res.unexpected) + }) + +module.exports = router diff --git a/packages/app/obojobo-module-selector/client/css/module-selector.scss b/packages/app/obojobo-module-selector/client/css/module-selector.scss index c91fa1ddc0..4dd20746d5 100644 --- a/packages/app/obojobo-module-selector/client/css/module-selector.scss +++ b/packages/app/obojobo-module-selector/client/css/module-selector.scss @@ -86,6 +86,16 @@ header { padding: 0 30px; } +.back-button { + position: absolute; + font-size: 12px; + cursor: pointer; + display: block; + text-align: left; + top: 10px; + left: 10px; +} + #list-header-wrapper { left: 0; right: 0; @@ -106,16 +116,6 @@ header { display: inline-block; } - #back-button { - position: absolute; - font-size: 12px; - cursor: pointer; - display: block; - text-align: left; - top: 10px; - left: 10px; - } - #select-section-title { display: inline; font-weight: 700; @@ -147,10 +147,33 @@ header { } } +.split-run-embed-button { + &.is-hidden { + display: none; + } +} + #list-container { position: relative; height: calc(100% - 166px); + #select-more-indicator { + font-size: 11pt; + line-height: 0.7em; + padding: 8px 12px; + } + + #select-more-indicator, + #split-run-list-embed-button-wrapper { + display: block; + margin: 0.2em auto 0; + text-align: center; + + &.is-hidden { + display: none; + } + } + .template { display: none; } @@ -218,6 +241,19 @@ header { display: none; height: 100vh; + .button.embed-button.disabled { + pointer-events: none; + opacity: 0.3; + filter: gray; + filter: grayscale(100%); + } + + .split-run-embed-button { + font-size: 11pt; + line-height: 0.7em; + padding: 8px 12px; + } + .new-module-button-wrapper { bottom: 0; background-color: #ffffff; @@ -232,6 +268,11 @@ header { padding: 8px 12px; font-size: 11pt; line-height: 0.7em; + + &.hidden { + opacity: 0; + pointer-events: none; + } } } @@ -336,6 +377,12 @@ header { overflow-y: scroll; } +#split-run-selected-modules-list:empty::after { + display: block; + content: 'No Modules Selected'; +} + +#section-embed-type-selection header .blue-flourish, #section-progress header .blue-flourish, #section-module-selection header .blue-flourish { height: 4px; @@ -346,6 +393,7 @@ header { top: 0; } +#section-embed-type-selection header .heading-container h1, #section-progress header .heading-container h1, #section-module-selection header .heading-container h1 { font-size: 17pt; @@ -354,12 +402,26 @@ header { word-break: break-word; } +#section-embed-type-selection header .heading-container h1 .remaining-tagline, #section-progress header .heading-container h1 .remaining-tagline, #section-module-selection header .heading-container h1 .remaining-tagline { display: inline-block; height: 26px; } +#section-module-selection header .heading-container { + position: relative; +} + +#section-module-selection header .heading-container #embed-type-indicator { + font-size: 14pt; + left: 50%; + position: absolute; + top: 100%; + transform: translateX(-50%); +} + +#section-embed-type-selection header .heading-container a, #section-module-selection header .heading-container a { font-size: 8pt; display: block; @@ -368,6 +430,7 @@ header { text-align: right; } +#section-embed-type-selection > p, #section-module-selection > p { font-weight: bold; font-size: 12pt; @@ -380,6 +443,7 @@ header { flex-wrap: wrap; } +#section-embed-type-selection .wizard-button-container, #section-module-selection .wizard-button-container { width: 100%; max-width: 247px; @@ -431,6 +495,15 @@ header { margin: 0 auto; text-align: center; margin-top: 20px; + + #split-run-import-clarification { + margin: 0.2em 1em 1em; + text-align: left; + + &.is-hidden { + display: none; + } + } } .buttons { @@ -438,6 +511,7 @@ header { } } +#section-embed-type-selection, #section-module-selection { display: none; text-align: center; @@ -446,6 +520,36 @@ header { transition: all 0.1s ease; } + #single-module-button { + .wizard-button { + background-color: lighten($color-personal-action, 40%); + background-image: url('./single-module-icon.png'); + background-repeat: no-repeat; + background-position: center 40px; + color: darken($color-personal-action, 30%); + } + + &:hover .wizard-button { + background-color: lighten($color-personal-action, 30%); + background-position: center 36px; + } + } + + #split-run-button { + .wizard-button { + background-color: lighten($color-action, 55%); + background-image: url('./split-run-icon.png'); + background-repeat: no-repeat; + background-position: center 20px; + color: darken($color-action, 10%); + } + + &:hover .wizard-button { + background-color: lighten($color-action, 50%); + background-position: center 16px; + } + } + #community-library-button { .wizard-button { background-color: lighten($color-action, 55%); @@ -477,6 +581,28 @@ header { } } +#section-module-selection { + #split-run-selected { + h2 { + margin: 0 auto; + } + + ul { + list-style-type: none; + padding: 0; + margin: 0; + } + + #split-run-category-embed-button { + margin: 0.2em 0 0; + } + + &.is-hidden { + display: none; + } + } +} + #section-success { display: none; text-align: center; diff --git a/packages/app/obojobo-module-selector/client/css/single-module-icon.png b/packages/app/obojobo-module-selector/client/css/single-module-icon.png new file mode 100644 index 0000000000..384309ed76 Binary files /dev/null and b/packages/app/obojobo-module-selector/client/css/single-module-icon.png differ diff --git a/packages/app/obojobo-module-selector/client/css/split-run-icon.png b/packages/app/obojobo-module-selector/client/css/split-run-icon.png new file mode 100644 index 0000000000..6f0d38e21c Binary files /dev/null and b/packages/app/obojobo-module-selector/client/css/split-run-icon.png differ diff --git a/packages/app/obojobo-module-selector/client/js/module-selector.js b/packages/app/obojobo-module-selector/client/js/module-selector.js index 054f9abef2..f1c2d67710 100644 --- a/packages/app/obojobo-module-selector/client/js/module-selector.js +++ b/packages/app/obojobo-module-selector/client/js/module-selector.js @@ -4,6 +4,7 @@ import '../css/module-selector.scss' const SETTINGS_IS_ASSIGNMENT = __isAssignment // eslint-disable-line no-undef const TAB_COMMUNITY = 'Community Library' const TAB_PERSONAL = 'My Modules' + const SECTION_EMBED_TYPE_SELECT = 'section-embed-type-selection' const SECTION_MODULE_SELECT = 'section-module-selection' const SECTION_SELECT_OBJECT = 'section-select-object' const SECTION_PROGRESS = 'section-progress' @@ -13,6 +14,7 @@ import '../css/module-selector.scss' const CHANGE_SECTION_FADE_DELAY_MS = 250 const MAX_ITEMS = 20 const MESSAGE_LOGOUT = 'You have been logged out. Please refresh the page and try again.' + const MAX_SPLIT_RUN_OPTION_LENGTH = 2 const searchStrings = {} const itemTemplateEl = document.querySelectorAll('.template.obo-item')[0] const listContainerEl = document.getElementById('list-container') @@ -31,6 +33,13 @@ import '../css/module-selector.scss' let allowScorePassback let sectionState = null + // control whether the embed is for a single module or a split-run scenario + // in the split-run scenario, two modules will need to be selected prior to + // the completion of the embed + // assume true for convenience + let embeddingSingleModule = true + let splitRunSelected = [] + function empty(el) { while (el.firstChild) el.removeChild(el.firstChild) } @@ -145,12 +154,6 @@ import '../css/module-selector.scss' // navigation function gotoSection(sectionId, skipFadeAnimation = false, addClass = '') { - sectionState = { - sectionId, - skipFadeAnimation, - addClass - } - if (sectionId === SECTION_PRE_PROGRESS) { showProgress() return @@ -177,6 +180,16 @@ import '../css/module-selector.scss' }) fadeIn(newSectionEl, CHANGE_SECTION_FADE_DELAY_MS) } + + // for every section EXCEPT the option selection page, keep track of the args + // so we can 'cancel' back to the correct state + if (sectionId !== SECTION_EMBED_OPTIONS) { + sectionState = { + sectionId, + skipFadeAnimation, + addClass + } + } } function gotoTab(newSection) { @@ -223,12 +236,23 @@ import '../css/module-selector.scss' progressBarContainerEl.classList.add('success') progressBarContainerEl.querySelector('span').innerHTML = 'Success!' progressBarValueEl.style.width = '100%' + + // TODO: this is all still currently built to only support a single selected module + // fix it to check the 'single/split-run' variable and adjust as needed setTimeout(() => { - const ltiData = buildContentItem( - selectedItem.title, - buildLaunchUrl(selectedItem.draftId), - SETTINGS_IS_ASSIGNMENT - ) + let title = '' + let url = '' + if (embeddingSingleModule) { + title = selectedItem.title + url = buildLaunchUrl([selectedItem.draftId]) + } else { + title = + 'Split-Run: ' + + splitRunSelected.reduce((prev, current) => `${prev.title} or ${current.title}`) + url = buildLaunchUrl(splitRunSelected.map(m => m.draftId)) + } + + const ltiData = buildContentItem(title, url, SETTINGS_IS_ASSIGNMENT) const formEl = document.getElementById('submit-form') formEl.querySelector('input[name=content_items]').value = JSON.stringify(ltiData) formEl.submit() @@ -252,12 +276,18 @@ import '../css/module-selector.scss' } } - function buildLaunchUrl(draftId) { + function buildLaunchUrl(draftIds) { + let draftString = '' + if (draftIds.length > 1) { + draftString = `?draftA=${draftIds[0]}&draftB=${draftIds[1]}` + } else { + draftString = draftIds[0] + } return ( window.location.origin + - '/view/' + - draftId + - '?score_import=' + + (embeddingSingleModule ? '/view/' : '/view-split') + + draftString + + (embeddingSingleModule ? '?score_import=' : '&score_import=') + (allowScorePassback ? 'true' : 'false') ) } @@ -271,6 +301,17 @@ import '../css/module-selector.scss' cloneEl.querySelector('.preview').setAttribute('href', '/preview/' + lo.draftId) cloneEl.setAttribute('data-lo-id', lo.draftId) cloneEl.querySelector('.button').addEventListener('click', onEmbedClick) + + if (!embeddingSingleModule) { + const alreadySelected = splitRunSelected.some(selected => selected.draftId === lo.draftId) + if (alreadySelected) cloneEl.querySelector('.button').classList.add('deselect-button') + cloneEl.querySelector('.button').innerHTML = alreadySelected ? 'Deselect' : 'Select' + + if (splitRunSelected.length >= MAX_SPLIT_RUN_OPTION_LENGTH && !alreadySelected) { + cloneEl.querySelector('.button').classList.add('disabled') + } + } + listEl.appendChild(cloneEl) } @@ -290,12 +331,14 @@ import '../css/module-selector.scss' title = 'Personal Library' apiUrl = '/api/drafts' color = 'blue' + document.getElementById('new-module').classList.remove('hidden') break case TAB_COMMUNITY: title = 'Community Collection' apiUrl = '/api/drafts-public' color = 'purple' + document.getElementById('new-module').classList.add('hidden') break } @@ -356,6 +399,40 @@ import '../css/module-selector.scss' hide(listEl.querySelector('.no-items')) } + function resetSplitRunSelectedSectionList() { + const listEl = document.getElementById('split-run-selected-modules-list') + listEl.innerHTML = '' + document.getElementById('select-more-count').innerHTML = + MAX_SPLIT_RUN_OPTION_LENGTH - splitRunSelected.length + + for (let i = 0; i < splitRunSelected.length; i++) { + appendListItem(splitRunSelected[i], listEl) + } + + if (splitRunSelected.length >= MAX_SPLIT_RUN_OPTION_LENGTH) { + // disable all 'Select' buttons + // leave 'Deselect' buttons functional + const selectButtons = document + .getElementById('list-container') + .querySelectorAll('.button.embed-button:not(.deselect-button)') + selectButtons.forEach(button => button.classList.add('disabled')) + + document.getElementById('select-more-indicator').classList.add('is-hidden') + document.getElementById('split-run-category-embed-button').classList.remove('is-hidden') + document.getElementById('split-run-list-embed-button-wrapper').classList.remove('is-hidden') + } else { + // enable all 'Select' buttons + const selectButtons = document + .getElementById('list-container') + .querySelectorAll('.button.embed-button:not(.deselect-button)') + selectButtons.forEach(button => button.classList.remove('disabled')) + + document.getElementById('select-more-indicator').classList.remove('is-hidden') + document.getElementById('split-run-category-embed-button').classList.add('is-hidden') + document.getElementById('split-run-list-embed-button-wrapper').classList.add('is-hidden') + } + } + // UI: function setupUI() { listContainerEl.querySelector('ul.template').remove() @@ -384,11 +461,36 @@ import '../css/module-selector.scss' } }) - document.getElementById('back-button').addEventListener('click', event => { + document.getElementById('type-back-button').addEventListener('click', event => { + event.preventDefault() + gotoSection(SECTION_EMBED_TYPE_SELECT) + }) + + document.getElementById('module-back-button').addEventListener('click', event => { event.preventDefault() gotoSection(SECTION_MODULE_SELECT) }) + document.getElementById('single-module-button').addEventListener('click', event => { + event.preventDefault() + gotoSection(SECTION_MODULE_SELECT, false) + embeddingSingleModule = true + document.getElementById('embed-type-indicator').innerHTML = 'Embedding Single Module' + splitRunSelected = [] + document.getElementById('split-run-selected').classList.add('is-hidden') + resetSplitRunSelectedSectionList() + }) + + document.getElementById('split-run-button').addEventListener('click', event => { + event.preventDefault() + gotoSection(SECTION_MODULE_SELECT, false) + embeddingSingleModule = false + document.getElementById('embed-type-indicator').innerHTML = 'Embedding Split-Run' + splitRunSelected = [] + document.getElementById('split-run-selected').classList.remove('is-hidden') + resetSplitRunSelectedSectionList() + }) + document.getElementById('community-library-button').addEventListener('click', event => { event.preventDefault() gotoSection(SECTION_SELECT_OBJECT, false, 'purple') @@ -401,6 +503,18 @@ import '../css/module-selector.scss' gotoTab(TAB_PERSONAL) }) + document.getElementById('split-run-category-embed-button').addEventListener('click', event => { + event.preventDefault() + document.getElementById('split-run-import-clarification').classList.remove('is-hidden') + gotoSection(SECTION_EMBED_OPTIONS) + }) + + document.getElementById('split-run-list-embed-button').addEventListener('click', event => { + event.preventDefault() + document.getElementById('split-run-import-clarification').classList.remove('is-hidden') + gotoSection(SECTION_EMBED_OPTIONS) + }) + document.getElementById('finish-button').addEventListener('click', event => { event.preventDefault() allowScorePassback = @@ -410,7 +524,7 @@ import '../css/module-selector.scss' document.getElementById('finish-cancel-button').addEventListener('click', event => { event.preventDefault() - gotoSection(SECTION_SELECT_OBJECT, sectionState.skipFadeAnimation, sectionState.addClass) + gotoSection(sectionState.sectionId, sectionState.skipFadeAnimation, sectionState.addClass) }) } @@ -420,7 +534,25 @@ import '../css/module-selector.scss' const draftId = oboItemEl.getAttribute('data-lo-id') selectedItem = getDraftById(draftId) - gotoSection(SECTION_EMBED_OPTIONS) + if (embeddingSingleModule) { + document.getElementById('split-run-import-clarification').classList.add('is-hidden') + return gotoSection(SECTION_EMBED_OPTIONS) + } + + // the same button serves two purposes - to select an unselected module or to deselect a selected module + // determine which by checking to see if the module this button belongs to is in the list of selected modules + const alreadySelected = splitRunSelected.some(selected => selected.draftId === draftId) + if (alreadySelected) { + splitRunSelected = splitRunSelected.filter(selected => selected.draftId !== draftId) + oboItemEl.querySelector('.button').classList.remove('deselect-button') + } else { + if (splitRunSelected.length >= MAX_SPLIT_RUN_OPTION_LENGTH) return + splitRunSelected.push(selectedItem) + oboItemEl.querySelector('.button').classList.add('deselect-button') + } + + oboItemEl.querySelector('.button').innerHTML = alreadySelected ? 'Select' : 'Deselect' + resetSplitRunSelectedSectionList() } function handleError(result) { @@ -456,5 +588,6 @@ import '../css/module-selector.scss' // initalize: setupUI() - gotoSection(SECTION_MODULE_SELECT) + // gotoSection(SECTION_MODULE_SELECT) + gotoSection(SECTION_EMBED_TYPE_SELECT) })() diff --git a/packages/app/obojobo-module-selector/server/views/module-selector.ejs b/packages/app/obojobo-module-selector/server/views/module-selector.ejs index b29b302b0f..c211be6efe 100644 --- a/packages/app/obojobo-module-selector/server/views/module-selector.ejs +++ b/packages/app/obojobo-module-selector/server/views/module-selector.ejs @@ -37,6 +37,10 @@
+
Allow importing previous scores for the same module?
> @@ -51,14 +55,50 @@
+
+
+
+

+ Embed an Obojobo® Next Module +

+
+
+
+ +

Choose between:

+ + + +
+

Embed an Obojobo® Next Module

+
+ Back

Choose between:

@@ -83,6 +123,17 @@
+ +
@@ -96,11 +147,17 @@ Refresh
- Back + Back
+ +