diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..43da9ce3c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.env +.mypy_cache +.pytest_cache +.vscode +*.egg-info +__pycache__ +dist +env +node_modules +client +e2e/node_modules +e2e/server/env +e2e/dist +server/env diff --git a/.fireq.json b/.fireq.json deleted file mode 100644 index 20c11c174..000000000 --- a/.fireq.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "superdesk_branch": "release/2.6" -} diff --git a/.github/workflows/lint-server.yml b/.github/workflows/lint-server.yml index 3d023aa37..02ec7a412 100644 --- a/.github/workflows/lint-server.yml +++ b/.github/workflows/lint-server.yml @@ -8,6 +8,8 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 + with: + python-version: '3.10' - run: pip install black~=23.0 - run: black --check server @@ -16,6 +18,8 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 + with: + python-version: '3.10' - run: pip install flake8 - run: flake8 server @@ -24,5 +28,7 @@ jobs: steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 + with: + python-version: '3.10' - run: pip install -Ur server/mypy-requirements.txt - run: mypy server diff --git a/client/actions/agenda.ts b/client/actions/agenda.ts index 225813f82..e2157511b 100644 --- a/client/actions/agenda.ts +++ b/client/actions/agenda.ts @@ -3,10 +3,12 @@ import {cloneDeep, pick, get, sortBy, findIndex} from 'lodash'; import {Moment} from 'moment'; import {IEventItem, IPlanningItem, IAgenda} from '../interfaces'; +import {planningApi} from '../superdeskApi'; import {AGENDA, MODALS, EVENTS} from '../constants'; -import {getErrorMessage, gettext, planningUtils, stringUtils} from '../utils'; +import {getErrorMessage, gettext, planningUtils} from '../utils'; import {planning, showModal, main} from './index'; +import {convertStringFields} from '../utils/strings'; const openAgenda = () => ( (dispatch) => ( @@ -238,6 +240,48 @@ const addEventToCurrentAgenda = ( } ); +export function convertEventToPlanningItem(event: IEventItem): Partial { + const defaultPlace = selectors.general.defaultPlaceList(planningApi.redux.store.getState()); + const defaultValues = planningUtils.defaultPlanningValues(null, defaultPlace); + + let newPlanningItem: Partial = { + ...defaultValues, + type: 'planning', + event_item: event._id, + planning_date: event._sortDate || event.dates?.start, + place: event.place || defaultPlace, + subject: event.subject, + anpa_category: event.anpa_category, + agendas: [], + language: event.language || defaultValues.language, + languages: event.languages || defaultValues.languages, + }; + + newPlanningItem = convertStringFields( + event, + newPlanningItem, + 'event', + 'planning', + [ + ['slugline', 'slugline'], + ['internal_note', 'internal_note'], + ['name', 'name'], + ['definition_short', 'description_text'], + ['ednote', 'ednote'], + ], + ) as Partial; + + if (event.languages != null) { + newPlanningItem.languages = event.languages; + } + + if (event.priority != null) { + newPlanningItem.priority = event.priority; + } + + return newPlanningItem; +} + /** * Action dispatcher that creates a planning item from the supplied event, * @param {object} event - The event used to create the planning item @@ -248,54 +292,19 @@ const createPlanningFromEvent = ( event: IEventItem, planningDate: Moment = null, agendas: Array = [] -) => ( - (dispatch) => ( - dispatch(planning.api.save({}, { - event_item: event._id, - slugline: stringUtils.convertStringFieldForProfileFieldType( - 'event', - 'planning', - 'slugline', - 'slugline', - event.slugline - ), - planning_date: planningDate || event._sortDate || event.dates.start, - internal_note: stringUtils.convertStringFieldForProfileFieldType( - 'event', - 'planning', - 'internal_note', - 'internal_note', - event.internal_note - ), - name: stringUtils.convertStringFieldForProfileFieldType( - 'event', - 'planning', - 'name', - 'name', - event.name - ), - place: event.place, - subject: event.subject, - anpa_category: event.anpa_category, - description_text: stringUtils.convertStringFieldForProfileFieldType( - 'event', - 'planning', - 'definition_short', - 'description_text', - event.definition_short - ), - ednote: stringUtils.convertStringFieldForProfileFieldType( - 'event', - 'planning', - 'ednote', - 'ednote', - event.ednote - ), - agendas: agendas, - language: event.language, - })) - ) -); +) => { + const newPlanningItem = convertEventToPlanningItem(event); + + if (planningDate != null) { + newPlanningItem.planning_date = planningDate; + } + + newPlanningItem.agendas = newPlanningItem.agendas.concat(agendas); + + return (dispatch) => ( + dispatch(planning.api.save({}, newPlanningItem)) + ); +}; /** * Action dispatcher that fetches all planning items for the diff --git a/client/actions/assignments/api.ts b/client/actions/assignments/api.ts index b451ccf8f..bec65419c 100644 --- a/client/actions/assignments/api.ts +++ b/client/actions/assignments/api.ts @@ -3,12 +3,13 @@ import {get, cloneDeep, has, pick} from 'lodash'; import {appConfig} from 'appConfig'; import {IAssignmentItem} from '../../interfaces'; +import {planningApi} from '../../superdeskApi'; import * as selectors from '../../selectors'; import * as actions from '../'; import {ASSIGNMENTS, ALL_DESKS, SORT_DIRECTION} from '../../constants'; import planningUtils from '../../utils/planning'; -import {lockUtils, getErrorMessage, isExistingItem, gettext} from '../../utils'; +import {getErrorMessage, isExistingItem, gettext} from '../../utils'; import planning from '../planning'; import {assignmentsViewRequiresArchiveItems} from '../../components/Assignments/AssignmentItem/fields'; @@ -240,24 +241,6 @@ const fetchAssignmentById = (id, force = false, recieve = true) => ( } ); -/** - * Action dispatcher to query the API for all Assignments that are currently locked - * @return Array of locked Assignments - */ -const queryLockedAssignments = () => ( - (dispatch, getState, {api}) => ( - api('assignments').query({ - source: JSON.stringify( - {query: {constant_score: {filter: {exists: {field: 'lock_session'}}}}} - ), - }) - .then( - (data) => Promise.resolve(data._items), - (error) => Promise.reject(error) - ) - ) -); - /** * Action to receive the list of Assignments and store them in the store * Also loads all the associated contacts (if any) @@ -300,7 +283,7 @@ const save = (original, assignmentUpdates) => ( return promise.then((originalItem) => { let updates; - if (original.lock_action === 'reassign') { + if (original.lock_action === ASSIGNMENTS.ITEM_ACTIONS.REASSIGN.lock_action) { updates = pick(assignmentUpdates, 'assigned_to'); updates.assigned_to = pick( assignmentUpdates.assigned_to, @@ -394,53 +377,6 @@ const revert = (item) => ( ) ); -/** - * Action to lock an assignment - * @param {IAssignmentItem} assignment - Assignment to be unlocked - * @param {String} action - The action to assign to the lock - * @return Promise - */ -const lock = (assignment: IAssignmentItem, action: string = 'edit') => ( - (dispatch, getState, {api, notify}) => { - if (lockUtils.isItemLockedInThisSession( - assignment, - selectors.general.session(getState()), - selectors.locks.getLockedItems(getState()) - )) { - return Promise.resolve(assignment); - } - - return api('assignments_lock', assignment).save({}, {lock_action: action}) - .then( - (lockedItem: IAssignmentItem) => lockedItem, - (error) => { - const msg = get(error, 'data._message') || 'Could not lock the assignment.'; - - notify.error(msg); - if (error) throw error; - }); - } -); - -/** - * Action to unlock an assignment - * @param {IAssignmentItem} assignment - Assignment to be unlocked - * @return Promise - */ -const unlock = (assignment: IAssignmentItem) => ( - (dispatch, getState, {api, notify}) => ( - api('assignments_unlock', assignment).save({}) - .then( - (unlockedItem: IAssignmentItem) => unlockedItem, - (error) => { - const msg = get(error, 'data._message') || 'Could not unlock the assignment.'; - - notify.error(msg); - throw error; - }) - ) -); - /** * Fetch history of an assignment * @param {object} assignment - The Assignment to load history for @@ -605,21 +541,21 @@ const removeAssignment = (assignment) => ( ) ); -const unlink = (assignment) => ( - (dispatch, getState, {api, notify}) => ( +function unlink(assignment: IAssignmentItem) { + return (dispatch, getState, {api, notify}) => ( api('assignments_unlink').save({}, { assignment_id: assignment._id, item_id: get(assignment, 'item_ids[0]'), }) .then(() => { notify.success(gettext('Assignment reverted.')); - return dispatch(self.unlock(assignment)); + return planningApi.locks.unlockItem(assignment); }, (error) => { notify.error(get(error, 'data._message') || gettext('Could not unlock the assignment.')); throw error; }) - ) -); + ); +} // eslint-disable-next-line consistent-this const self = { @@ -631,9 +567,6 @@ const self = { createFromTemplateAndShow, complete, revert, - lock, - unlock, - queryLockedAssignments, loadPlanningAndEvent, loadArchiveItems, loadArchiveItem, diff --git a/client/actions/assignments/notifications.ts b/client/actions/assignments/notifications.ts index bc2489b50..ce0bb89f0 100644 --- a/client/actions/assignments/notifications.ts +++ b/client/actions/assignments/notifications.ts @@ -1,10 +1,15 @@ +import {get, cloneDeep} from 'lodash'; + +import {IWebsocketMessageData} from '../../interfaces'; + +import {planningApi} from '../../superdeskApi'; +import {ASSIGNMENTS, WORKSPACE, MODALS} from '../../constants'; +import {lockUtils, assignmentUtils, gettext, isExistingItem} from '../../utils'; + import * as selectors from '../../selectors'; import assignments from './index'; import main from '../main'; -import {get, cloneDeep} from 'lodash'; import planning from '../planning'; -import {ASSIGNMENTS, WORKSPACE, MODALS} from '../../constants'; -import {lockUtils, assignmentUtils, gettext, isExistingItem} from '../../utils'; import {hideModal, showModal} from '../index'; import * as actions from '../../actions'; @@ -149,6 +154,12 @@ const onAssignmentUpdated = (_e, data) => ( lock_time: null, }; + planningApi.locks.setItemAsUnlocked({ + item: data.item, + etag: data.etag, + from_ingest: false, + type: 'assignment', + }); dispatch({ type: ASSIGNMENTS.ACTIONS.UNLOCK_ASSIGNMENT, payload: {assignment: item}, @@ -185,9 +196,10 @@ const _updatePlannigRelatedToAssignment = (data) => ( } ); -const onAssignmentLocked = (_e, data) => ( - (dispatch) => { +function onAssignmentLocked(_e, data: IWebsocketMessageData['ITEM_LOCKED']) { + return (dispatch) => { if (get(data, 'item')) { + planningApi.locks.setItemAsLocked(data); return dispatch(assignments.api.fetchAssignmentById(data.item, false)) .then((assignmentInStore) => { let item = { @@ -209,8 +221,8 @@ const onAssignmentLocked = (_e, data) => ( } return Promise.resolve(); - } -); + }; +} /** * WS Action when a Planning item gets unlocked @@ -220,9 +232,10 @@ const onAssignmentLocked = (_e, data) => ( * @param {object} _e - Event object * @param {object} data - Planning and User IDs */ -const onAssignmentUnlocked = (_e, data) => ( - (dispatch, getState) => { +function onAssignmentUnlocked(_e, data: IWebsocketMessageData['ITEM_UNLOCKED']) { + return (dispatch, getState) => { if (get(data, 'item')) { + planningApi.locks.setItemAsUnlocked(data); return dispatch(assignments.api.fetchAssignmentById(data.item, false)) .then((assignmentInStore) => { const locks = selectors.locks.getLockedItems(getState()); @@ -265,8 +278,8 @@ const onAssignmentUnlocked = (_e, data) => ( return Promise.resolve(); }); } - } -); + }; +} /** * WS Action when an Assignment is deleted diff --git a/client/actions/assignments/tests/api_test.ts b/client/actions/assignments/tests/api_test.ts index 7a2f6e48b..784566571 100644 --- a/client/actions/assignments/tests/api_test.ts +++ b/client/actions/assignments/tests/api_test.ts @@ -417,7 +417,7 @@ describe('actions.assignments.api', () => { }); describe('queryLockedAssignments', () => { - it('queries for locked assignments', (done) => ( + xit('queries for locked assignments', (done) => ( store.test(done, assignmentsApi.queryLockedAssignments()) .then(() => { const query = {constant_score: {filter: {exists: {field: 'lock_session'}}}}; @@ -528,55 +528,6 @@ describe('actions.assignments.api', () => { }); }); - describe('assignments_lock', () => { - beforeEach(() => { - services.api('assignments_lock').save = sinon.spy(() => Promise.resolve(data.assignments[0])); - services.api('assignments_unlock').save = sinon.spy(() => Promise.resolve(data.assignments[0])); - }); - - afterEach(() => { - restoreSinonStub(services.api('assignments_lock').save); - restoreSinonStub(services.api('assignments_unlock').save); - }); - - it('calls lock endpoint if assignment not locked', (done) => { - store.test(done, assignmentsApi.lock(data.assignments[0])) - .then(() => { - expect(services.api('assignments_lock').save.callCount).toBe(1); - expect(services.api('assignments_lock').save.args[0]).toEqual([ - {}, - {lock_action: 'edit'}, - ]); - done(); - }) - .catch(done.fail); - }); - - it('does not call lock endpoint if assignment already locked', (done) => { - store.initialState.assignment.assignments['1'] = { - ...store.initialState.assignment.assignments['1'], - lock_user: 'ident1', - lock_session: 'session1', - }; - store.test(done, assignmentsApi.lock(store.initialState.assignment.assignments['1'])) - .then((item) => { - expect(services.api('assignments_lock').save.callCount).toBe(0); - expect(item).toEqual(store.initialState.assignment.assignments[1]); - done(); - }) - .catch(done.fail); - }); - - it('calls unlock endpoint', (done) => { - store.test(done, assignmentsApi.unlock(data.assignments[0])) - .then(() => { - expect(services.api('assignments_unlock').save.callCount).toBe(1); - done(); - }) - .catch(done.fail); - }); - }); - it('removeAssignment', (done) => ( store.test(done, assignmentsApi.removeAssignment(data.assignments[0])) .then(() => { diff --git a/client/actions/assignments/tests/notification_test.ts b/client/actions/assignments/tests/notification_test.ts index ba1560e99..7750e90d3 100644 --- a/client/actions/assignments/tests/notification_test.ts +++ b/client/actions/assignments/tests/notification_test.ts @@ -1,5 +1,6 @@ import sinon from 'sinon'; +import {planningApi} from '../../../superdeskApi'; import {getTestActionStore, restoreSinonStub} from '../../../utils/testUtils'; import {createTestStore, assignmentUtils} from '../../../utils'; import {registerNotifications} from '../../../utils/notifications'; @@ -8,7 +9,7 @@ import assignmentsUi from '../ui'; import assignmentsApi from '../api'; import main from '../../main'; import assignmentNotifications from '../notifications'; -import planningApi from '../../planning/api'; +import planningApis from '../../planning/api'; describe('actions.assignments.notification', () => { let store; @@ -134,7 +135,7 @@ describe('actions.assignments.notification', () => { () => () => Promise.resolve() ); sinon.stub(assignmentUtils, 'getCurrentSelectedDeskId').returns('desk1'); - sinon.stub(planningApi, 'loadPlanningByIds').callsFake( + sinon.stub(planningApis, 'loadPlanningByIds').callsFake( () => () => Promise.resolve() ); }); @@ -143,7 +144,7 @@ describe('actions.assignments.notification', () => { restoreSinonStub(assignmentsUi.reloadAssignments); restoreSinonStub(assignmentUtils.getCurrentSelectedDeskId); restoreSinonStub(main.fetchItemHistory); - restoreSinonStub(planningApi.loadPlanningByIds); + restoreSinonStub(planningApis.loadPlanningByIds); }); it('update planning on assignment update', (done) => { @@ -164,8 +165,8 @@ describe('actions.assignments.notification', () => { testStore.dispatch(assignmentNotifications.onAssignmentUpdated({}, payload)) .then(() => { - expect(planningApi.loadPlanningByIds.callCount).toBe(1); - expect(planningApi.loadPlanningByIds.args).toEqual([ + expect(planningApis.loadPlanningByIds.callCount).toBe(1); + expect(planningApis.loadPlanningByIds.args).toEqual([ [['p1']], ]); expect(assignmentsUi.reloadAssignments.callCount).toBe(2); @@ -234,11 +235,15 @@ describe('actions.assignments.notification', () => { describe('`assignment lock`', () => { beforeEach(() => { + sinon.stub(planningApi.locks, 'setItemAsLocked').returns(undefined); + sinon.stub(planningApi.locks, 'setItemAsUnlocked').returns(undefined); sinon.stub(assignmentsApi, 'fetchAssignmentById').callsFake(() => ( Promise.resolve(store.initialState.assignment.assignments.as1))); }); afterEach(() => { + restoreSinonStub(planningApi.locks.setItemAsLocked); + restoreSinonStub(planningApi.locks.setItemAsUnlocked); restoreSinonStub(assignmentsApi.fetchAssignmentById); }); @@ -254,6 +259,7 @@ describe('actions.assignments.notification', () => { return store.test(done, assignmentNotifications.onAssignmentLocked({}, payload)) .then(() => { + expect(planningApi.locks.setItemAsLocked.callCount).toBe(1); expect(store.dispatch.callCount).toBe(2); expect(assignmentsApi.fetchAssignmentById.callCount).toBe(1); expect(store.dispatch.args[1]).toEqual([{ @@ -282,6 +288,7 @@ describe('actions.assignments.notification', () => { return store.test(done, assignmentNotifications.onAssignmentUnlocked({}, payload)) .then(() => { + expect(planningApi.locks.setItemAsUnlocked.callCount).toBe(1); expect(store.dispatch.callCount).toBe(2); expect(assignmentsApi.fetchAssignmentById.callCount).toBe(1); expect(store.dispatch.args[1]).toEqual([{ @@ -305,20 +312,22 @@ describe('actions.assignments.notification', () => { describe('`assignment:completed`', () => { beforeEach(() => { + sinon.stub(planningApi.locks, 'setItemAsUnlocked').returns(undefined); sinon.stub(assignmentsUi, 'queryAndGetMyAssignments').callsFake( () => () => (Promise.resolve()) ); sinon.stub(assignmentUtils, 'getCurrentSelectedDeskId').returns('desk1'); - sinon.stub(planningApi, 'loadPlanningByIds').callsFake( + sinon.stub(planningApis, 'loadPlanningByIds').callsFake( () => () => (Promise.resolve()) ); }); afterEach(() => { + restoreSinonStub(planningApi.locks.setItemAsUnlocked); restoreSinonStub(assignmentsUi.reloadAssignments); restoreSinonStub(assignmentsUi.queryAndGetMyAssignments); restoreSinonStub(assignmentUtils.getCurrentSelectedDeskId); - restoreSinonStub(planningApi.loadPlanningByIds); + restoreSinonStub(planningApis.loadPlanningByIds); }); it('update planning on assignment complete', (done) => { @@ -344,8 +353,8 @@ describe('actions.assignments.notification', () => { .then(() => { coverage1 = getCoverage(payload); - expect(planningApi.loadPlanningByIds.callCount).toBe(1); - expect(planningApi.loadPlanningByIds.args).toEqual([ + expect(planningApis.loadPlanningByIds.callCount).toBe(1); + expect(planningApis.loadPlanningByIds.args).toEqual([ [['p1']], ]); expect(assignmentsUi.reloadAssignments.callCount).toBe(2); @@ -375,6 +384,7 @@ describe('actions.assignments.notification', () => { return store.test(done, assignmentNotifications.onAssignmentUpdated({}, payload)) .then(() => { + expect(planningApi.locks.setItemAsUnlocked.callCount).toBe(1); expect(assignmentsApi.fetchAssignmentById.callCount).toBe(1); expect(store.dispatch.args[5]).toEqual([{ type: 'UNLOCK_ASSIGNMENT', diff --git a/client/actions/assignments/tests/ui_test.ts b/client/actions/assignments/tests/ui_test.ts index 1a11f60be..4073ee67a 100644 --- a/client/actions/assignments/tests/ui_test.ts +++ b/client/actions/assignments/tests/ui_test.ts @@ -1,8 +1,8 @@ import sinon from 'sinon'; +import {planningApi} from '../../../superdeskApi'; import assignmentsUi from '../ui'; import assignmentsApi from '../api'; -import planningApi from '../../planning/api'; import {getTestActionStore, restoreSinonStub} from '../../../utils/testUtils'; import * as testData from '../../../utils/testData'; import {ASSIGNMENTS, ALL_DESKS} from '../../../constants'; @@ -19,22 +19,17 @@ describe('actions.assignments.ui', () => { services = store.services; data = store.data; + sinon.stub(planningApi.locks, 'lockItem').callsFake((item) => Promise.resolve(item)); + sinon.stub(planningApi.locks, 'unlockItem').callsFake((item) => Promise.resolve(item)); sinon.stub(assignmentsApi, 'link').callsFake(() => (Promise.resolve())); - sinon.stub(assignmentsApi, 'lock').callsFake((item) => (Promise.resolve(item))); - sinon.stub(assignmentsApi, 'unlock').callsFake((item) => (Promise.resolve(item))); sinon.stub(assignmentsApi, 'query').callsFake(() => (Promise.resolve({_items: []}))); - - sinon.stub(planningApi, 'lock').callsFake((item) => Promise.resolve(item)); - sinon.stub(planningApi, 'unlock').callsFake((item) => Promise.resolve(item)); }); afterEach(() => { + restoreSinonStub(planningApi.locks.lockItem); + restoreSinonStub(planningApi.locks.unlockItem); restoreSinonStub(assignmentsApi.link); - restoreSinonStub(assignmentsApi.lock); - restoreSinonStub(assignmentsApi.unlock); restoreSinonStub(assignmentsApi.query); - restoreSinonStub(planningApi.lock); - restoreSinonStub(planningApi.unlock); }); describe('onFulFilAssignment', () => { @@ -302,285 +297,20 @@ describe('actions.assignments.ui', () => { }); }); - describe('lockPlanning', () => { - it('Locks the planning item associated with the Assignment', (done) => ( - store.test(done, assignmentsUi.lockPlanning({planning_item: 'plan1'}, 'locker')) - .then(() => { - expect(planningApi.lock.callCount).toBe(1); - expect(planningApi.lock.args[0]).toEqual([{_id: 'plan1'}, 'locker']); - - done(); - }) - ).catch(done.fail)); - - it('Notifies the user if the planning lock fails', (done) => { - restoreSinonStub(planningApi.lock); - sinon.stub(planningApi, 'lock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.lockPlanning( - {planning_item: 'plan1'}, - 'locker' - )) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - }); - - describe('lockAssignment', () => { - it('Locks the Assignment', (done) => ( - store.test(done, assignmentsUi.lockAssignment(data.assignments[0], 'locker')) - .then(() => { - expect(assignmentsApi.lock.callCount).toBe(1); - expect(assignmentsApi.lock.args[0]).toEqual([data.assignments[0], 'locker']); - - done(); - }) - ).catch(done.fail)); - - it('Notifies the user if the assignment lock fails', (done) => { - restoreSinonStub(assignmentsApi.lock); - sinon.stub(assignmentsApi, 'lock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.lockAssignment( - data.assignments[0], - 'locker' - )) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - }); - - describe('unlockPlanning', () => { - it('Unlocks the planning item associated with the Assignment', (done) => ( - store.test(done, assignmentsUi.unlockPlanning({planning_item: 'plan1'})) - .then(() => { - expect(planningApi.unlock.callCount).toBe(1); - expect(planningApi.unlock.args[0]).toEqual([{_id: 'plan1'}]); - - done(); - }) - ).catch(done.fail)); - - it('Notifies the user if the planning unlock fails', (done) => { - restoreSinonStub(planningApi.unlock); - sinon.stub(planningApi, 'unlock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.unlockPlanning({planning_item: 'plan1'})) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - }); - - describe('unlockAssignment', () => { - it('Unlocks the Assignment', (done) => ( - store.test(done, assignmentsUi.unlockAssignment(data.assignments[0])) - .then(() => { - expect(assignmentsApi.unlock.callCount).toBe(1); - expect(assignmentsApi.unlock.args[0]).toEqual([data.assignments[0]]); - - done(); - }) - ).catch(done.fail)); - - it('Notifies the user if the assignment unlock fails', (done) => { - restoreSinonStub(assignmentsApi.unlock); - sinon.stub(assignmentsApi, 'unlock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.unlockAssignment(data.assignments[0])) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - }); - - describe('lockAssignmentAndPlanning', () => { - beforeEach(() => { - sinon.stub(assignmentsUi, 'lockAssignment').callsFake((item) => Promise.resolve(item)); - sinon.stub(assignmentsUi, 'lockPlanning').callsFake((item) => Promise.resolve( - {_id: item.planning_item} - )); - }); - - afterEach(() => { - restoreSinonStub(assignmentsUi.lockAssignment); - restoreSinonStub(assignmentsUi.lockPlanning); - }); - - it('locks both Assignment and Planning and returns the locked Assignment', (done) => ( - store.test(done, assignmentsUi.lockAssignmentAndPlanning(data.assignments[0], 'locker')) - .then((item) => { - expect(item).toEqual(data.assignments[0]); - - expect(assignmentsUi.lockPlanning.callCount).toBe(1); - expect(assignmentsUi.lockPlanning.args[0]).toEqual([data.assignments[0], 'locker']); - - expect(assignmentsUi.lockAssignment.callCount).toBe(1); - expect(assignmentsUi.lockAssignment.args[0]).toEqual([ - data.assignments[0], - 'locker', - ]); - - done(); - }) - ).catch(done.fail)); - - it('Notifies the user if locking Assignment fails', (done) => { - restoreSinonStub(assignmentsUi.lockAssignment); - restoreSinonStub(assignmentsUi.lockPlanning); - - restoreSinonStub(assignmentsApi.lock); - sinon.stub(assignmentsApi, 'lock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.lockAssignmentAndPlanning( - data.assignments[0], - 'locker' - )) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - - it('Notifies the user if locking Planning fails', (done) => { - restoreSinonStub(assignmentsUi.lockAssignment); - restoreSinonStub(assignmentsUi.lockPlanning); - - restoreSinonStub(planningApi.lock); - sinon.stub(planningApi, 'lock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.lockAssignmentAndPlanning( - data.assignments[0], - 'locker' - )) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - }); - - describe('unlockAssignmentAndPlanning', () => { - beforeEach(() => { - sinon.stub(assignmentsUi, 'unlockAssignment').callsFake( - (item) => Promise.resolve(item) - ); - sinon.stub(assignmentsUi, 'unlockPlanning').callsFake((item) => Promise.resolve( - {_id: item.planning_item} - )); - }); - - afterEach(() => { - restoreSinonStub(assignmentsUi.unlockAssignment); - restoreSinonStub(assignmentsUi.unlockPlanning); - }); - - it('unlocks both Assignment and Planning and returns the locked Assignment', (done) => ( - store.test(done, assignmentsUi.unlockAssignmentAndPlanning(data.assignments[0])) - .then((item) => { - expect(item).toEqual(data.assignments[0]); - - expect(assignmentsUi.unlockPlanning.callCount).toBe(1); - expect(assignmentsUi.unlockPlanning.args[0]).toEqual([data.assignments[0]]); - - expect(assignmentsUi.unlockAssignment.callCount).toBe(1); - expect(assignmentsUi.unlockAssignment.args[0]).toEqual([data.assignments[0]]); - - done(); - }) - ).catch(done.fail)); - - it('Notifies the user if unlocking Assignment fails', (done) => { - restoreSinonStub(assignmentsUi.unlockAssignment); - restoreSinonStub(assignmentsUi.unlockPlanning); - - restoreSinonStub(assignmentsApi.unlock); - sinon.stub(assignmentsApi, 'unlock').returns(Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.unlockAssignmentAndPlanning(data.assignments[0])) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - - it('Notifies the user if unlocking Planning fails', (done) => { - restoreSinonStub(assignmentsUi.unlockAssignment); - restoreSinonStub(assignmentsUi.unlockPlanning); - - restoreSinonStub(planningApi.unlock); - sinon.stub(planningApi, 'unlock').callsFake(() => Promise.reject(errorMessage)); - - return store.test(done, assignmentsUi.unlockAssignmentAndPlanning(data.assignments[0])) - .then(() => { /* no-op */ }, (error) => { - expect(error).toEqual(errorMessage); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual(['Failed!']); - - done(); - }) - .catch(done.fail); - }); - }); - describe('showRemoveAssignmentModal', () => { - beforeEach(() => { - sinon.stub(assignmentsUi, 'lockAssignment').callsFake( - (item) => Promise.resolve(item) - ); - }); - - afterEach(() => { - restoreSinonStub(assignmentsUi.lockAssignment); - }); - it('locks only Assignment and displays the confirmation dialog', (done) => ( store.test(done, assignmentsUi.showRemoveAssignmentModal(data.assignments[0])) .then((item) => { expect(item).toEqual(data.assignments[0]); - expect(assignmentsUi.lockAssignment.callCount).toBe(1); - expect(assignmentsUi.lockAssignment.args[0]).toEqual([ + expect(planningApi.locks.lockItem.callCount).toBe(1); + expect(planningApi.locks.lockItem.args[0]).toEqual([ data.assignments[0], - 'remove_assignment', + 'remove_assignment' ]); - expect(store.dispatch.callCount).toBe(2); - expect(store.dispatch.args[1]).toEqual([{ + expect(store.dispatch.callCount).toBe(1); + expect(store.dispatch.args[0]).toEqual([{ type: 'SHOW_MODAL', modalType: 'CONFIRMATION', modalProps: jasmine.objectContaining( @@ -594,10 +324,8 @@ describe('actions.assignments.ui', () => { ).catch(done.fail)); it('returns Promise.reject on locking error', (done) => { - restoreSinonStub(assignmentsUi.lockAssignment); - sinon.stub(assignmentsUi, 'lockAssignment').returns( - Promise.reject(errorMessage) - ); + restoreSinonStub(planningApi.locks.lockItem); + sinon.stub(planningApi.locks, 'lockItem').returns(Promise.reject(errorMessage)); return store.test(done, assignmentsUi.showRemoveAssignmentModal(data.assignments[0])) .then(() => { /* no-op */ }, (error) => { diff --git a/client/actions/assignments/ui.ts b/client/actions/assignments/ui.ts index ef788479c..02022aea7 100644 --- a/client/actions/assignments/ui.ts +++ b/client/actions/assignments/ui.ts @@ -1,8 +1,11 @@ import {get, cloneDeep, forEach} from 'lodash'; import moment from 'moment'; + +import {planningApi} from '../../superdeskApi'; +import {IAssignmentItem} from '../../interfaces'; + import {showModal} from '../index'; import assignments from './index'; -import planningApi from '../planning/api'; import * as selectors from '../../selectors'; import * as actions from '../../actions'; import {ASSIGNMENTS, MODALS, WORKSPACE, ALL_DESKS} from '../../constants'; @@ -390,7 +393,7 @@ const reassign = (assignment) => ( (dispatch) => dispatch(self._openActionModal( assignment, ASSIGNMENTS.ITEM_ACTIONS.REASSIGN.actionName, - 'reassign' + ASSIGNMENTS.ITEM_ACTIONS.REASSIGN.lock_action )) ); @@ -402,7 +405,7 @@ const editPriority = (assignment) => ( (dispatch) => dispatch(_openActionModal( assignment, ASSIGNMENTS.ITEM_ACTIONS.EDIT_PRIORITY.actionName, - 'edit_priority' + ASSIGNMENTS.ITEM_ACTIONS.EDIT_PRIORITY.lock_action, )) ); @@ -415,7 +418,7 @@ const save = (original, updates) => ( (dispatch, getState, {notify}) => ( dispatch(assignments.api.save(original, updates)) .then((updatedItem) => { - notify.success(get(original, 'lock_action') === 'reassign' ? + notify.success(get(original, 'lock_action') === ASSIGNMENTS.ITEM_ACTIONS.REASSIGN.lock_action ? gettext('The assignment was reassigned.') : gettext('Assignment priority has been updated.') ); @@ -462,9 +465,9 @@ const onFulFilAssignment = (assignment) => ( } ); -const complete = (item) => ( - (dispatch, getState, {notify}) => ( - dispatch(self.lockAssignment(item, 'complete')) +function complete(item: IAssignmentItem) { + return (dispatch, getState, {notify}) => ( + planningApi.locks.lockItem(item, 'complete') .then((lockedItem) => { dispatch(assignments.api.complete(lockedItem)) .then((lockedItem) => { @@ -474,15 +477,15 @@ const complete = (item) => ( notify.error(getErrorMessage(error, 'Failed to complete the assignment.')); // unlock the assignment - return dispatch(self.unlockAssignment(lockedItem)); + return planningApi.locks.unlockItem(lockedItem); }); }, (error) => Promise.reject(error)) - ) -); + ); +} -const revert = (item) => ( - (dispatch, getState, {notify}) => ( - dispatch(self.lockAssignment(item, 'revert')) +function revert(item: IAssignmentItem) { + return (dispatch, getState, {notify}) => ( + planningApi.locks.lockItem(item, 'revert') .then((lockedItem) => { const contentTypes = selectors.general.contentTypes(getState()); @@ -503,15 +506,15 @@ const revert = (item) => ( modalProps: { body: gettext('This will unlink the text item associated with the assignment. Are you sure ?'), action: () => dispatch(assignments.api.unlink(lockedItem)), - onCancel: () => dispatch(self.unlockAssignment(lockedItem)), + onCancel: () => planningApi.locks.unlockItem(lockedItem), autoClose: true, }, })); return Promise.resolve(); }, (error) => Promise.reject(error)) - ) -); + ); +} /** * Action for launching the modal form for fulfil assignment and add to planning @@ -569,11 +572,11 @@ const canLinkItem = (item) => ( ) ); -const validateStartWorkingOnScheduledUpdate = (assignment) => ( - (dispatch, getState, {notify}) => ( +function validateStartWorkingOnScheduledUpdate(assignment: IAssignmentItem) { + return (dispatch, getState, {notify}) => ( // Validate the coverage to see if all preceeding scheduled_updates / coverage // is linked to an item - dispatch(planningApi.loadPlanningByIds([get(assignment, 'planning_item')], false)).then( + planningApi.planning.getById(assignment.planning_item, false).then( (plannings) => { const planning = get(plannings, '[0]'); @@ -611,11 +614,11 @@ const validateStartWorkingOnScheduledUpdate = (assignment) => ( return Promise.resolve(); } ) - ) -); + ); +} -const startWorking = (assignment) => ( - (dispatch, getState, {templates, session, desks, notify}) => { +function startWorking(assignment: IAssignmentItem) { + return (dispatch, getState, {templates, session, desks, notify}) => { let promise = Promise.resolve(); if (get(assignment, 'scheduled_update_id')) { @@ -623,7 +626,7 @@ const startWorking = (assignment) => ( } promise.then(() => - (dispatch(self.lockAssignment(assignment, 'start_working')) + (planningApi.locks.lockItem(assignment, 'start_working') .then((lockedAssignment) => { const currentDesk = assignmentUtils.getCurrentSelectedDesk(desks, getState()); const defaultTemplateId = get(currentDesk, 'default_content_template') || null; @@ -654,15 +657,12 @@ const startWorking = (assignment) => ( assignment._id, template.template_name )).catch((error) => { - dispatch(self.unlockAssignment(assignment)); + planningApi.locks.unlockItem(assignment); notify.error(getErrorMessage(error, gettext('Failed to create an archive item.'))); return Promise.reject(error); }) ); - - const onCancel = () => ( - dispatch(assignments.api.unlock(lockedAssignment)) - ); + const onCancel = () => planningApi.locks.unlockItem(lockedAssignment); return dispatch(showModal({ modalType: MODALS.SELECT_DESK_TEMPLATE, @@ -678,12 +678,12 @@ const startWorking = (assignment) => ( }, (error) => Promise.reject(error)) ), (error) => Promise.resolve() ); - } -); + }; +} -const _openActionModal = (assignment, action, lockAction = null) => ( - (dispatch) => ( - dispatch(self.lockAssignment(assignment, lockAction)) +function _openActionModal(assignment: IAssignmentItem, action: string, lockAction: string = 'edit') { + return (dispatch) => ( + planningApi.locks.lockItem(assignment, lockAction) .then((lockedAssignment) => ( dispatch(showModal({ modalType: MODALS.ITEM_ACTIONS_MODAL, @@ -693,149 +693,8 @@ const _openActionModal = (assignment, action, lockAction = null) => ( }, })) ), (error) => Promise.reject(error)) - ) -); - -/** - * Utility Action to lock the Assignment, and display a notification - * to the user if the lock fails - * @param {object} assignment - The Assignment to lock - * @param {string} action - The action for the lock - * @return Promise - The locked Assignment item, otherwise the API error - */ -const lockAssignment = (assignment, action) => ( - (dispatch, getState, {notify}) => ( - dispatch(assignments.api.lock(assignment, action)) - .then( - (lockedAssignment) => Promise.resolve(lockedAssignment), - (error) => { - notify.error( - getErrorMessage(error, 'Failed to lock the Assignment.') - ); - - return Promise.reject(error); - } - ) - ) -); - -/** - * Utility Action to lock a Planning item associated with an Assignment, and - * displays a notification to the user if the lock fails - * @param {object} assignment - The Assignment for the associated Planning item - * @param {string} action - The action for the lock - * @return Promise - The locked Planning item, otherwise the API error - */ -const lockPlanning = (assignment, action) => ( - (dispatch, getState, {notify}) => ( - dispatch(actions.planning.api.lock({_id: get(assignment, 'planning_item')}, action)) - .then( - (lockedPlanning) => Promise.resolve(lockedPlanning), - (error) => { - notify.error( - getErrorMessage(error, 'Failed to lock the Planning item.') - ); - - return Promise.reject(error); - } - ) - ) -); - -/** - * Utility Action to lock both the Assignment and it's associated Planning item - * @param {object} assignment - The Assignment to lock for - * @param {string} action - The action for the lock - * @return Promise - The locked Assignment item, otherwise the API error - */ -const lockAssignmentAndPlanning = (assignment, action) => ( - (dispatch) => { - let planning = null; - - return dispatch(self.lockPlanning(assignment, action)) - .then( - (lockedPlan) => { - planning = lockedPlan; - return dispatch(self.lockAssignment(assignment, action)); - } - ) - .then( - (lockedItem) => Promise.resolve(lockedItem), - (error) => { - if (!planning) { - return Promise.reject(error); - } - return dispatch(self.unlockPlanning(assignment)) - .then( - () => Promise.reject(error), - () => Promise.reject(error) - ); - } - ); - } -); - -/** - * Utility Action to unlock an Assignment and display a notification - * if the unlock fails - * @param {object} assignment - The Assignment to unlock - * @return Promise - The unlocked Assignment item, otherwise the API error - */ -const unlockAssignment = (assignment) => ( - (dispatch, getState, {notify}) => ( - dispatch(assignments.api.unlock(assignment)) - .then( - (unlockedAssignment) => Promise.resolve(unlockedAssignment), - (error) => { - notify.error( - getErrorMessage(error, gettext('Failed to unlock the Assignment')) - ); - - return Promise.reject(error); - } - ) - ) -); - -/** - * Utility Action to unlock a Planning item associated with an Assignment, and - * display a notification to the user if the unlock fails - * @param assignment - * @return Promise - The unlocked Planning item, otherwise the API error - */ -const unlockPlanning = (assignment) => ( - (dispatch, getState, {notify}) => ( - dispatch(actions.planning.api.unlock({_id: get(assignment, 'planning_item')})) - .then( - (unlockedPlanning) => Promise.resolve(unlockedPlanning), - (error) => { - notify.error( - getErrorMessage(error, 'Failed to unlock the Planning item') - ); - - return Promise.reject(error); - } - ) - ) -); - -/** - * Utility Action to unlock both the Assignment and it's associated Planning item - * @param {object} assignment - The Assignment to lock for - * @return Promise - The unlocked Assignment item, otherwise the API error - */ -const unlockAssignmentAndPlanning = (assignment) => ( - (dispatch) => ( - Promise.all([ - dispatch(self.unlockAssignment(assignment)), - dispatch(self.unlockPlanning(assignment)), - ]) - .then( - (data) => Promise.resolve(data[0]), - (error) => Promise.reject(error) - ) - ) -); + ); +} /** * Action to display the 'Remove Assignment' confirmation modal @@ -844,9 +703,9 @@ const unlockAssignmentAndPlanning = (assignment) => ( * @param {object} assignment - The Assignment item intended for deletion * @return Promise - Locked Assignment, otherwise the Lock API error */ -const showRemoveAssignmentModal = (assignment) => ( - (dispatch) => ( - dispatch(self.lockAssignment(assignment, ASSIGNMENTS.ITEM_ACTIONS.REMOVE.lock_action)) +function showRemoveAssignmentModal(assignment: IAssignmentItem) { + return (dispatch) => ( + planningApi.locks.lockItem(assignment, ASSIGNMENTS.ITEM_ACTIONS.REMOVE.lock_action) .then((lockedAssignment) => { dispatch(showModal({ modalType: MODALS.CONFIRMATION, @@ -854,7 +713,7 @@ const showRemoveAssignmentModal = (assignment) => ( body: gettext('This will also remove other linked assignments (if any, for story updates). ' + 'Are you sure?'), action: () => dispatch(self.removeAssignment(lockedAssignment)), - onCancel: () => dispatch(self.unlockAssignment(lockedAssignment)), + onCancel: () => planningApi.locks.unlockItem(lockedAssignment), autoClose: true, }, })); @@ -862,16 +721,16 @@ const showRemoveAssignmentModal = (assignment) => ( return Promise.resolve(lockedAssignment); }, (error) => Promise.reject(error) ) - ) -); + ); +} /** * Action to delete the Assignment item * @param {object} assignment - The Assignment item to remove * @return Promise - Empty promise, otherwise the API error */ -const removeAssignment = (assignment) => ( - (dispatch, getState, {notify}) => ( +function removeAssignment(assignment: IAssignmentItem) { + return (dispatch, getState, {notify}) => ( dispatch(assignments.api.removeAssignment(assignment)) .then(() => { notify.success('Assignment removed'); @@ -880,11 +739,11 @@ const removeAssignment = (assignment) => ( notify.error( getErrorMessage(error, 'Failed to remove the Assignment') ); - dispatch(self.unlockAssignment(assignment)); + planningApi.locks.unlockItem(assignment); return Promise.reject(error); }) - ) -); + ); +} const setListGroups = (groupKeys) => ({ type: ASSIGNMENTS.ACTIONS.SET_GROUP_KEYS, @@ -1032,12 +891,6 @@ const self = { showRemoveAssignmentModal, removeAssignment, updatePreviewItemOnRouteUpdate, - lockAssignment, - lockPlanning, - lockAssignmentAndPlanning, - unlockAssignment, - unlockPlanning, - unlockAssignmentAndPlanning, openArchivePreview, setMyAssignmentsTotal, setListGroups, diff --git a/client/actions/events/api.ts b/client/actions/events/api.ts index 6bb6ec1ab..24cf37a19 100644 --- a/client/actions/events/api.ts +++ b/client/actions/events/api.ts @@ -1,8 +1,8 @@ import {get, isEqual, cloneDeep, pickBy, has, find, every} from 'lodash'; +import {planningApi} from '../../superdeskApi'; import {ISearchSpikeState, IEventSearchParams, IEventItem, IPlanningItem} from '../../interfaces'; import {appConfig} from 'appConfig'; -import {planningApis} from '../../api'; import { EVENTS, @@ -13,7 +13,6 @@ import { import * as selectors from '../../selectors'; import { eventUtils, - lockUtils, getErrorMessage, isExistingItem, isValidFileInput, @@ -23,7 +22,7 @@ import { getTimeZoneOffset, } from '../../utils'; -import planningApi from '../planning/api'; +import planningApis from '../planning/api'; import eventsUi from './ui'; import main from '../main'; import {eventParamsToSearchParams} from '../../utils/search'; @@ -45,7 +44,7 @@ const loadEventsByRecurrenceId = ( loadToStore: boolean = true ) => ( (dispatch) => ( - planningApis.events.search({ + planningApi.events.search({ recurrence_id: rid, spike_state: spikeState, page: page, @@ -144,7 +143,7 @@ function query( } } - return planningApis.events.search(eventParamsToSearchParams({ + return planningApi.events.search(eventParamsToSearchParams({ ...params, itemIds: itemIds, filter_id: params.filter_id || selectors.main.currentSearchFilterId(getState()), @@ -203,7 +202,7 @@ function loadEventDataForAction( _plannings: Array; _relatedPlannings: Array; }> { - return planningApis.combined.getRecurringEventsAndPlanningItems(event, loadPlanning, loadEvents) + return planningApi.combined.getRecurringEventsAndPlanningItems(event, loadPlanning, loadEvents) .then((items) => ({ ...event, _recurring: items.events, @@ -230,7 +229,7 @@ const loadAssociatedPlannings = (event) => ( return Promise.resolve([]); } - return dispatch(planningApi.loadPlanningByEventId(event._id)); + return dispatch(planningApis.loadPlanningByEventId(event._id)); } ); @@ -267,68 +266,6 @@ const receiveEvents = (events, skipEvents: Array = []) => ({ receivedAt: Date.now(), }); -/** - * Action to lock an Event - * @param {Object} event - Event to be unlocked - * @param {String} action - The lock action - * @return Promise - */ -const lock = (event, action = 'edit') => ( - (dispatch, getState, {api, notify}) => { - if (action === null || - lockUtils.isItemLockedInThisSession( - event, - selectors.general.session(getState()), - selectors.locks.getLockedItems(getState()) - ) - ) { - return Promise.resolve(event); - } - - return api('events_lock', event).save({}, {lock_action: action}) - .then( - (item) => { - // On lock, file object in the event is lost, so, replace it from original event - item.files = event.files; - eventUtils.modifyForClient(item); - - dispatch({ - type: EVENTS.ACTIONS.LOCK_EVENT, - payload: {event: item}, - }); - - return Promise.resolve(item); - }, (error) => { - const msg = get(error, 'data._message') || 'Could not lock the event.'; - - notify.error(msg); - if (error) throw error; - }); - } -); - -const unlock = (event) => ( - (dispatch, getState, {api, notify}) => ( - api('events_unlock', event).save({}) - .then( - (item) => { - dispatch({ - type: EVENTS.ACTIONS.UNLOCK_EVENT, - payload: {event: item}, - }); - - return Promise.resolve(item); - }, - (error) => { - notify.error( - getErrorMessage(error, 'Could not unlock the event') - ); - return Promise.reject(error); - } - ) - ) -); - /** * Action Dispatcher to fetch events from the server, * and add them to the store without adding them to the events list @@ -343,7 +280,7 @@ function silentlyFetchEventsById( saveToStore: boolean = true ) { return (dispatch) => ( - planningApis.events.getByIds( + planningApi.events.getByIds( ids.filter((v, i, a) => (a.indexOf(v) === i)), spikeState ) @@ -379,7 +316,7 @@ const fetchById = (eventId, {force = false, saveToStore = true, loadPlanning = t if (has(storedEvents, eventId) && !force) { promise = Promise.resolve(storedEvents[eventId]); } else { - promise = planningApis.events.getById(eventId) + promise = planningApi.events.getById(eventId) .then((event) => { if (saveToStore) { dispatch(self.receiveEvents([event])); @@ -516,14 +453,27 @@ const markEventCancelled = (eventId, etag, reason, occurStatus, cancelledItems, }, }); -const markEventPostponed = (event, reason, actionedDate) => ({ - type: EVENTS.ACTIONS.MARK_EVENT_POSTPONED, - payload: { - event: event, - reason: reason, - actionedDate: actionedDate, - }, -}); +function markEventPostponed(event: IEventItem, reason: string, actionedDate: string) { + return (dispatch) => { + planningApi.locks.setItemAsUnlocked({ + item: event._id, + type: event.type, + recurrence_id: event.recurrence_id, + etag: event._etag, + from_ingest: false, + user: event.lock_user, + lock_session: event.lock_session, + }); + dispatch({ + type: EVENTS.ACTIONS.MARK_EVENT_POSTPONED, + payload: { + event: event, + reason: reason, + actionedDate: actionedDate, + }, + }); + }; +} const markEventHasPlannings = (event, planning) => ({ type: EVENTS.ACTIONS.MARK_EVENT_HAS_PLANNINGS, @@ -633,8 +583,8 @@ const save = (original, updates) => ( EVENTS.UPDATE_METHODS[0].value; return originalEvent?._id != null ? - planningApis.events.update(originalItem, eventUpdates) : - planningApis.events.create(eventUpdates); + planningApi.events.update(originalItem, eventUpdates) : + planningApi.events.create(eventUpdates); }); } ); @@ -771,8 +721,6 @@ const self = { query, refetch, receiveEvents, - lock, - unlock, silentlyFetchEventsById, cancelEvent, markEventCancelled, diff --git a/client/actions/events/notifications.ts b/client/actions/events/notifications.ts index 89f9ca0c1..b23bdb77e 100644 --- a/client/actions/events/notifications.ts +++ b/client/actions/events/notifications.ts @@ -1,12 +1,15 @@ +import {get} from 'lodash'; + +import {planningApi} from '../../superdeskApi'; import {IWebsocketMessageData, ITEM_TYPE} from '../../interfaces'; import * as selectors from '../../selectors'; -import {WORKFLOW_STATE, EVENTS} from '../../constants'; +import {WORKFLOW_STATE, EVENTS, LOCKS} from '../../constants'; +import {gettext, dispatchUtils, getErrorMessage, lockUtils} from '../../utils'; + import eventsApi from './api'; import eventsUi from './ui'; import main from '../main'; -import planningApi from '../planning/api'; -import {get} from 'lodash'; -import {gettext, dispatchUtils, getErrorMessage, lockUtils} from '../../utils'; +import planningApis from '../planning/api'; import eventsPlanning from '../eventsPlanning'; /** @@ -36,13 +39,13 @@ function onEventUnlocked(_e: {}, data: IWebsocketMessageData['ITEM_UNLOCKED']) { let eventInStore = get(events, data.item, {}); const isCurrentlyLocked = lockUtils.isItemLocked(eventInStore, selectors.locks.getLockedItems(state)); + dispatch(main.onItemUnlocked(data, eventInStore, ITEM_TYPE.EVENT)); + if (!isCurrentlyLocked && eventInStore?.lock_session == null) { // No need to announce an unlock, as we have already done so return Promise.resolve(eventInStore); } - dispatch(main.onItemUnlocked(data, eventInStore, ITEM_TYPE.EVENT)); - eventInStore = { recurrence_id: get(data, 'recurrence_id') || null, ...eventInStore, @@ -69,6 +72,8 @@ function onEventUnlocked(_e: {}, data: IWebsocketMessageData['ITEM_UNLOCKED']) { const onEventLocked = (_e, data) => ( (dispatch, getState) => { if (data && data.item) { + planningApi.locks.setItemAsLocked(data); + const sessionId = selectors.general.session(getState()).sessionId; return dispatch(eventsApi.getEvent(data.item, false)) @@ -217,7 +222,7 @@ const onEventPostChanged = (e, data) => ( const storedEvent = selectors.events.storedEvents(getState())[data.item]; if (!posted && get(storedEvent, 'planning_ids.length', 0) > 0) { - dispatch(planningApi.loadPlanningByEventId(data.item)); + dispatch(planningApis.loadPlanningByEventId(data.item)); } } return Promise.resolve(); diff --git a/client/actions/events/tests/api_test.ts b/client/actions/events/tests/api_test.ts index a6e315000..49bee1d10 100644 --- a/client/actions/events/tests/api_test.ts +++ b/client/actions/events/tests/api_test.ts @@ -825,101 +825,4 @@ describe('actions.events.api', () => { .catch(done.fail); }); }); - - describe('lock/unlock', () => { - let mockStore; - let mocks; - let getLocks = () => selectors.locks.getLockedItems(mockStore.getState()); - - beforeEach(() => { - mocks = { - api: sinon.spy(() => mocks), - save: sinon.spy((original, updates = {}) => Promise.resolve({ - ...data.events[0], - ...updates, - })), - }; - - store.init(); - }); - - it('calls lock endpoint and updates the redux store', (done) => { - mockStore = createTestStore({ - initialState: store.initialState, - extraArguments: { - api: mocks.api, - }, - }); - - expect(getLocks().event).toEqual({}); - - mockStore.dispatch(eventsApi.lock(data.events[0])) - .then(() => { - expect(mocks.api.callCount).toBe(1); - expect(mocks.api.args[0]).toEqual([ - 'events_lock', - data.events[0], - ]); - - expect(mocks.save.callCount).toBe(1); - expect(mocks.save.args[0]).toEqual([ - {}, - {lock_action: 'edit'}, - ]); - - expect(getLocks().event).toEqual({ - e1: jasmine.objectContaining({ - action: 'edit', - item_type: 'event', - item_id: 'e1', - }), - }); - - done(); - }) - .catch(done.fail); - }); - - it('calls unlock endpoint and updates the redux store', (done) => { - store.initialState.locks.event = { - e1: { - action: 'edit', - item_type: 'event', - item_id: 'e1', - }, - }; - - mockStore = createTestStore({ - initialState: store.initialState, - extraArguments: { - api: mocks.api, - }, - }); - - expect(getLocks().event).toEqual({ - e1: jasmine.objectContaining({ - action: 'edit', - item_type: 'event', - item_id: 'e1', - }), - }); - - mockStore.dispatch(eventsApi.unlock(data.events[0])) - .then(() => { - expect(mocks.api.callCount).toBe(1); - expect(mocks.api.args[0]).toEqual([ - 'events_unlock', - data.events[0], - ]); - - expect(mocks.save.callCount).toBe(1); - expect(mocks.save.args[0]).toEqual([{}]); - - expect(getLocks().event).toEqual({}); - - done(); - }) - .catch(done.fail); - }); - }); }); diff --git a/client/actions/events/tests/notifications_test.ts b/client/actions/events/tests/notifications_test.ts index 8925064f2..4ab954bfe 100644 --- a/client/actions/events/tests/notifications_test.ts +++ b/client/actions/events/tests/notifications_test.ts @@ -1,7 +1,8 @@ +import {planningApi} from '../../../superdeskApi'; import eventsApi from '../api'; import eventsUi from '../ui'; import eventsPlanningUi from '../../eventsPlanning/ui'; -import planningApi from '../../planning/api'; +import planningApis from '../../planning/api'; import main from '../../main'; import sinon from 'sinon'; import {registerNotifications} from '../../../utils'; @@ -266,13 +267,13 @@ describe('actions.events.notifications', () => { describe('onEventPostChanged', () => { beforeEach(() => { restoreSinonStub(eventsNotifications.onEventPostChanged); - sinon.stub(planningApi, 'loadPlanningByEventId').callsFake( + sinon.stub(planningApis, 'loadPlanningByEventId').callsFake( () => (Promise.resolve()) ); }); afterEach(() => { - restoreSinonStub(planningApi.loadPlanningByEventId); + restoreSinonStub(planningApis.loadPlanningByEventId); }); xit('dispatches `MARK_EVENT_POSTED`', (done) => ( @@ -443,7 +444,7 @@ describe('actions.events.notifications', () => { pubstatus: 'cancelled', }, }]); - expect(planningApi.loadPlanningByEventId.callCount).toBe(1); + expect(planningApis.loadPlanningByEventId.callCount).toBe(1); done(); }) ).catch(done.fail)); @@ -460,7 +461,7 @@ describe('actions.events.notifications', () => { )) .then(() => { expect(store.dispatch.callCount).toBe(6); - expect(planningApi.loadPlanningByEventId.callCount).toBe(1); + expect(planningApis.loadPlanningByEventId.callCount).toBe(1); done(); }) ).catch(done.fail)); @@ -468,10 +469,12 @@ describe('actions.events.notifications', () => { describe('onEventLocked', () => { beforeEach(() => { + sinon.stub(planningApi.locks, 'setItemAsLocked').returns(undefined); sinon.stub(eventsApi, 'getEvent').returns(Promise.resolve(data.events[0])); }); afterEach(() => { + restoreSinonStub(planningApi.locks.setItemAsLocked); restoreSinonStub(eventsApi.getEvent); }); @@ -488,6 +491,8 @@ describe('actions.events.notifications', () => { } )) .then(() => { + expect(planningApi.locks.setItemAsLocked.callCount).toBe(1); + expect(eventsApi.getEvent.callCount).toBe(1); expect(eventsApi.getEvent.args[0]).toEqual([ 'e1', @@ -514,6 +519,7 @@ describe('actions.events.notifications', () => { describe('onEventUnlocked', () => { beforeEach(() => { + sinon.stub(planningApi.locks, 'setItemAsUnlocked').returns(undefined); store.initialState.events.events.e1.lock_user = 'ident1'; store.initialState.events.events.e1.lock_session = 'session1'; store.initialState.events.events.e1.lock_time = '2022-06-15T13:01:11+0000'; @@ -521,6 +527,7 @@ describe('actions.events.notifications', () => { }); afterEach(() => { + restoreSinonStub(planningApi.locks.setItemAsUnlocked); restoreSinonStub(main.changeEditorAction); }); @@ -553,6 +560,7 @@ describe('actions.events.notifications', () => { } )) .then(() => { + expect(planningApi.locks.setItemAsUnlocked.callCount).toBe(1); const modalStr = 'The Event you were editing was unlocked by "{{ userName }}"'; expect(store.dispatch.args[2][0].type).toEqual('AUTOSAVE_REMOVE'); diff --git a/client/actions/events/tests/ui_test.ts b/client/actions/events/tests/ui_test.ts index 7061caa8d..6982b7bf3 100644 --- a/client/actions/events/tests/ui_test.ts +++ b/client/actions/events/tests/ui_test.ts @@ -2,10 +2,11 @@ import {omit} from 'lodash'; import sinon from 'sinon'; import moment from 'moment'; +import {planningApi} from '../../../superdeskApi'; import {LIST_VIEW_TYPE} from '../../../interfaces'; import eventsApi from '../api'; import eventsUi from '../ui'; -import planningApi from '../../planning/api'; +import planningApis from '../../planning/api'; import {main} from '../../'; import {MAIN, EVENTS, ITEM_TYPE} from '../../../constants'; @@ -43,19 +44,19 @@ describe('actions.events.ui', () => { sinon.stub(eventsUi, 'refetch').callsFake(() => (Promise.resolve())); - sinon.stub(planningApi, 'loadPlanningByEventId').callsFake( + sinon.stub(planningApis, 'loadPlanningByEventId').callsFake( () => (Promise.resolve(data.plannings)) ); - sinon.stub(planningApi, 'fetch').callsFake(() => (Promise.resolve([]))); + sinon.stub(planningApis, 'fetch').callsFake(() => (Promise.resolve([]))); sinon.stub(eventsUi, 'setEventsList').callsFake(() => (Promise.resolve())); sinon.stub(eventsApi, 'loadEventDataForAction').callsFake( (event) => (Promise.resolve(event)) ); - sinon.stub(eventsApi, 'lock').callsFake((item) => (Promise.resolve(item))); - sinon.stub(eventsApi, 'unlock').callsFake((item) => (Promise.resolve(item))); + sinon.stub(planningApi.locks, 'lockItem').callsFake((item) => Promise.resolve(item)); + sinon.stub(planningApi.locks, 'unlockItem').callsFake((item) => Promise.resolve(item)); sinon.stub(eventsApi, 'rescheduleEvent').callsFake(() => (Promise.resolve())); @@ -72,11 +73,11 @@ describe('actions.events.ui', () => { restoreSinonStub(eventsUi.refetch); restoreSinonStub(eventsUi.setEventsList); restoreSinonStub(eventsApi.loadEventDataForAction); - restoreSinonStub(eventsApi.lock); - restoreSinonStub(eventsApi.unlock); + restoreSinonStub(planningApi.locks.lockItem); + restoreSinonStub(planningApi.locks.unlockItem); restoreSinonStub(eventsApi.rescheduleEvent); - restoreSinonStub(planningApi.loadPlanningByEventId); - restoreSinonStub(planningApi.fetch); + restoreSinonStub(planningApis.loadPlanningByEventId); + restoreSinonStub(planningApis.fetch); restoreSinonStub(eventsUi._openActionModalFromEditor); }); @@ -88,7 +89,7 @@ describe('actions.events.ui', () => { data.events[1], {}, 'onSpikeEvent', - null, + 'spike', true, false, false, @@ -187,8 +188,8 @@ describe('actions.events.ui', () => { true, false )).then(() => { - expect(eventsApi.lock.callCount).toBe(1); - expect(eventsApi.lock.args[0]).toEqual([data.events[1], 'cancel']); + expect(planningApi.locks.lockItem.callCount).toBe(1); + expect(planningApi.locks.lockItem.args[0]).toEqual([data.events[1], 'cancel']); expect(eventsApi.loadEventDataForAction.callCount).toBe(1); expect(eventsApi.loadEventDataForAction.args[0]).toEqual([ @@ -198,8 +199,8 @@ describe('actions.events.ui', () => { true, ]); - expect(store.dispatch.callCount).toBe(2); - expect(store.dispatch.args[1]).toEqual([{ + expect(store.dispatch.callCount).toBe(1); + expect(store.dispatch.args[0]).toEqual([{ type: 'SHOW_MODAL', modalType: 'ITEM_ACTIONS_MODAL', modalProps: { @@ -215,8 +216,8 @@ describe('actions.events.ui', () => { ).catch(done.fail)); it('openActionModal displays error message if lock fails', (done) => { - restoreSinonStub(eventsApi.lock); - sinon.stub(eventsApi, 'lock').callsFake(() => (Promise.reject(errorMessage))); + restoreSinonStub(planningApi.locks.lockItem); + sinon.stub(planningApi.locks, 'lockItem').callsFake(() => Promise.reject(errorMessage)); return store.test(done, eventsUi._openActionModal( data.events[1], 'Cancel Event', @@ -483,14 +484,12 @@ describe('actions.events.ui', () => { describe('rescheduleEvent', () => { beforeEach(() => { - // sinon.stub(main, 'lockAndEdit').callsFake((item) => Promise.resolve(item)); sinon.stub(main, 'openForEdit'); sinon.stub(eventsApi, 'fetchById').callsFake(() => Promise.resolve(data.events[1])); }); afterEach(() => { restoreSinonStub(eventsApi.rescheduleEvent); - // restoreSinonStub(main.lockAndEdit); restoreSinonStub(main.openForEdit); restoreSinonStub(eventsApi.fetchById); }); @@ -631,12 +630,10 @@ describe('actions.events.ui', () => { describe('createEventFromPlanning', () => { beforeEach(() => { - sinon.stub(planningApi, 'lock').callsFake((item) => Promise.resolve(item)); sinon.stub(main, 'createNew').callsFake((item) => Promise.resolve(item)); }); afterEach(() => { - restoreSinonStub(planningApi.lock); restoreSinonStub(main.createNew); }); @@ -645,8 +642,8 @@ describe('actions.events.ui', () => { store.test(done, eventsUi.createEventFromPlanning(plan)) .then(() => { - expect(planningApi.lock.callCount).toBe(1); - expect(planningApi.lock.args[0]).toEqual([plan, 'add_as_event']); + expect(planningApi.locks.lockItem.callCount).toBe(1); + expect(planningApi.locks.lockItem.args[0]).toEqual([plan, 'add_as_event']); expect(main.createNew.callCount).toBe(1); const args = main.createNew.args[0]; diff --git a/client/actions/events/ui.ts b/client/actions/events/ui.ts index 3863dddcf..2e9728337 100644 --- a/client/actions/events/ui.ts +++ b/client/actions/events/ui.ts @@ -2,12 +2,12 @@ import {get} from 'lodash'; import moment from 'moment-timezone'; import {appConfig} from 'appConfig'; +import {planningApi} from '../../superdeskApi'; import {IPlanningItem, IEventItem} from '../../interfaces'; -import {showModal, main, locks, addEventToCurrentAgenda} from '../index'; +import {showModal, main, addEventToCurrentAgenda} from '../index'; import {EVENTS, MODALS, SPIKED_STATE, MAIN, ITEM_TYPE, POST_STATE} from '../../constants'; import eventsApi from './api'; -import planningApi from '../planning/api'; import * as selectors from '../../selectors'; import { eventUtils, @@ -22,6 +22,7 @@ import { isItemPublic, stringUtils, } from '../../utils'; +import {convertStringFields} from '../../utils/strings'; /** * Action Dispatcher to fetch events from the server @@ -171,7 +172,7 @@ const openSpikeModal = (event, post = false, modalProps = {}) => ( eventWithData, {}, EVENTS.ITEM_ACTIONS.SPIKE.actionName, - null, + EVENTS.ITEM_ACTIONS.SPIKE.lock_action, true, post, false, @@ -187,7 +188,7 @@ const openUnspikeModal = (event, post = false) => ( event, {}, EVENTS.ITEM_ACTIONS.UNSPIKE.actionName, - null, + EVENTS.ITEM_ACTIONS.UNSPIKE.lock_action, true, post )) @@ -209,7 +210,7 @@ const openUpdateTimeModal = (event, post = false, fromEditor = true) => { event, {}, EVENTS.ITEM_ACTIONS.UPDATE_TIME.actionName, - null, + EVENTS.ITEM_ACTIONS.UPDATE_TIME.lock_action, true, post ); @@ -233,7 +234,7 @@ const openCancelModal = (event, post = false, fromEditor = true) => { event, {}, EVENTS.ITEM_ACTIONS.CANCEL_EVENT.actionName, - null, + EVENTS.ITEM_ACTIONS.CANCEL_EVENT.lock_action, true, post ); @@ -256,7 +257,7 @@ const openPostponeModal = (event, post = false, fromEditor = true) => { event, {}, EVENTS.ITEM_ACTIONS.POSTPONE_EVENT.actionName, - null, + EVENTS.ITEM_ACTIONS.POSTPONE_EVENT.lock_action, true, post ); @@ -279,7 +280,7 @@ const openRescheduleModal = (event, post = false, fromEditor = true) => { event, {}, EVENTS.ITEM_ACTIONS.RESCHEDULE_EVENT.actionName, - null, + EVENTS.ITEM_ACTIONS.RESCHEDULE_EVENT.lock_action, true, post ); @@ -403,10 +404,20 @@ const _openActionModalFromEditor = ({ Promise.resolve(modifiedEvent); if (get(previousLock, 'action')) { - promise.then((refetchedEvent) => ( - (openInEditor || openInModal) ? - dispatch(main.openForEdit(refetchedEvent, !openInModal, openInModal)) : - dispatch(locks.lock(refetchedEvent, previousLock.action)) + promise.then((refetchedItem) => ( + planningApi.locks.lockItem(refetchedItem, previousLock.action) + .then((lockedItem) => { + if (openInEditor || openInModal) { + dispatch(main.openEditorAction( + lockedItem, + 'edit', + true, + openInModal, + )); + } + + return lockedItem; + }) ), () => Promise.reject()); } @@ -432,7 +443,7 @@ const _openActionModal = ( modalProps = {} ) => ( (dispatch, getState, {notify}) => ( - dispatch(eventsApi.lock(original, lockAction)) + planningApi.locks.lockItem(original, lockAction) .then((lockedEvent) => ( eventsApi.loadEventDataForAction(lockedEvent, loadPlannings, post, loadEvents) .then((eventDetail) => ( @@ -730,15 +741,19 @@ const receiveEventHistory = (eventHistoryItems) => ({ */ const createEventFromPlanning = (plan: IPlanningItem) => ( (dispatch, getState) => { - const defaultDurationOnChange = selectors.forms.defaultEventDuration(getState()); - const occurStatuses = selectors.vocabs.eventOccurStatuses(getState()); + const state = getState(); + const defaultDurationOnChange = selectors.forms.defaultEventDuration(state); + const occurStatuses = selectors.vocabs.eventOccurStatuses(state); + const defaultCalendar = selectors.events.defaultCalendarValue(state); + const defaultPlace = selectors.general.defaultPlaceList(state); const unplannedStatus = getItemInArrayById(occurStatuses, 'eocstat:eos0', 'qcode') || { label: 'Unplanned event', qcode: 'eocstat:eos0', name: 'Unplanned event', }; const eventProfile = selectors.forms.eventProfile(getState()); - const newEvent: Partial = { + let newEvent: Partial = { + ...eventUtils.defaultEventValues(occurStatuses, defaultCalendar, defaultPlace), dates: { start: moment(plan.planning_date).clone(), end: moment(plan.planning_date) @@ -746,56 +761,45 @@ const createEventFromPlanning = (plan: IPlanningItem) => ( .add(defaultDurationOnChange, 'h'), tz: moment.tz.guess(), }, - name: plan.name?.length ? - stringUtils.convertStringFieldForProfileFieldType( - 'planning', - 'event', - 'name', - 'name', - plan.name - ) : - stringUtils.convertStringFieldForProfileFieldType( - 'planning', - 'event', - 'slugline', - 'name', - plan.slugline - ), subject: plan.subject, anpa_category: plan.anpa_category, - definition_short: stringUtils.convertStringFieldForProfileFieldType( - 'planning', - 'event', - 'description_text', - 'definition_short', - plan.description_text - ), calendars: [], - internal_note: stringUtils.convertStringFieldForProfileFieldType( - 'planning', - 'event', - 'internal_note', - 'internal_note', - plan.internal_note - ), place: plan.place, occur_status: unplannedStatus, _planning_item: plan._id, language: plan.language, }; + if (plan.languages != null) { + newEvent.languages = plan.languages; + } + + const fieldsToConvert: Array<[keyof IPlanningItem, keyof IEventItem]> = [ + ['description_text', 'definition_short'], + ['internal_note', 'internal_note'], + ['slugline', 'slugline'], + ]; + + if (plan.name?.length) { + fieldsToConvert.push(['name', 'name']); + } else { + fieldsToConvert.push(['slugline', 'name']); + } + if (get(eventProfile, 'editor.slugline.enabled', false)) { - newEvent.slugline = stringUtils.convertStringFieldForProfileFieldType( - 'planning', - 'event', - 'slugline', - 'slugline', - plan.slugline - ); + fieldsToConvert.push(['slugline', 'slugline']); } + newEvent = convertStringFields( + plan, + newEvent, + 'planning', + 'event', + fieldsToConvert, + ); + return Promise.all([ - dispatch(planningApi.lock(plan, 'add_as_event')), + planningApi.locks.lockItem(plan, 'add_as_event'), dispatch(main.createNew(ITEM_TYPE.EVENT, newEvent)), ]); } @@ -829,7 +833,8 @@ const selectCalendar = (calendarId = '', params = {}) => ( const onEventEditUnlock = (event) => ( (dispatch) => ( - get(event, '_planning_item') ? dispatch(planningApi.unlock({_id: event._planning_item})) : + get(event, '_planning_item') ? + planningApi.locks.unlockItemById(event._planning_item, 'planning') : Promise.resolve() ) ); @@ -852,7 +857,7 @@ const lockAndSaveUpdates = ( } // Otherwise lock, save and unlock this Event - return dispatch(locks.lock(event, lockAction)) + planningApi.locks.lockItem(event, lockAction) .then((original) => ( dispatch(main.saveAndUnlockItem(original, updates, true)) .then((item) => { @@ -966,7 +971,7 @@ const onMarkEventCompleted = (event, editor = false) => ( event, gettext('Save changes before marking event as complete ?'), (unlockedItem, previousLock, openInEditor, openInModal) => ( - dispatch(locks.lock(unlockedItem, EVENTS.ITEM_ACTIONS.MARK_AS_COMPLETED.lock_action)) + planningApi.locks.lockItem(unlockedItem, EVENTS.ITEM_ACTIONS.MARK_AS_COMPLETED.lock_action) .then((lockedItem) => ( dispatch(showModal({ modalType: MODALS.CONFIRMATION, @@ -976,18 +981,21 @@ const onMarkEventCompleted = (event, editor = false) => ( dispatch(main.saveAndUnlockItem(lockedItem, updates, true)).then((result) => { if (get(previousLock, 'action') && (openInEditor || openInModal)) { dispatch(main.openForEdit(result, true, openInModal)); - dispatch(locks.lock(result, previousLock.action)); + planningApi.locks.lockItem(result, previousLock.action); } }, (error) => { - dispatch(locks.unlock(lockedItem)); + planningApi.locks.unlockItem(lockedItem); }), - onCancel: () => dispatch(locks.unlock(lockedItem)).then((result) => { + onCancel: () => planningApi.locks.unlockItem(lockedItem).then((result) => { if (get(previousLock, 'action') && (openInEditor || openInModal)) { dispatch(main.openForEdit(result, true, openInModal)); - dispatch(locks.lock(result, previousLock.action)); + planningApi.locks.lockItem(result, previousLock.action); } }), autoClose: true, + // Add the event to modalProps, so if this item was unlocked by someone else + // this modal will close + original: event, }, }))), (error) => { notify.error(getErrorMessage(error, gettext('Could not obtain lock on the event.'))); @@ -996,17 +1004,20 @@ const onMarkEventCompleted = (event, editor = false) => ( } // If actioned on list / preview - return dispatch(locks.lock(event, EVENTS.ITEM_ACTIONS.MARK_AS_COMPLETED.lock_action)) + return planningApi.locks.lockItem(event, EVENTS.ITEM_ACTIONS.MARK_AS_COMPLETED.lock_action) .then((original) => ( dispatch(showModal({ modalType: MODALS.CONFIRMATION, modalProps: { body: gettext('Are you sure you want to mark this event as complete?'), action: () => dispatch(main.saveAndUnlockItem(original, updates, true)).catch((error) => { - dispatch(locks.unlock(original)); + planningApi.locks.unlockItem(original); }), - onCancel: () => dispatch(locks.unlock(original)), + onCancel: () => planningApi.locks.unlockItem(original), autoClose: true, + // Add the event to modalProps, so if this item was unlocked by someone else + // this modal will close + original: event, }, }))), (error) => { notify.error(getErrorMessage(error, gettext('Could not obtain lock on the event.'))); diff --git a/client/actions/index.ts b/client/actions/index.ts index f3ccd726c..35d79b4bb 100644 --- a/client/actions/index.ts +++ b/client/actions/index.ts @@ -5,7 +5,6 @@ import * as editors from './editor'; import planning from './planning/index'; import events from './events/index'; -import locks from './locks'; import assignments from './assignments/index'; import autosave from './autosave'; import main from './main'; @@ -60,7 +59,6 @@ export { events, resetStore, initStore, - locks, assignments, autosave, main, diff --git a/client/actions/locks.ts b/client/actions/locks.ts deleted file mode 100644 index ee726e5a4..000000000 --- a/client/actions/locks.ts +++ /dev/null @@ -1,154 +0,0 @@ -import {get} from 'lodash'; -import * as selectors from '../selectors'; -import {LOCKS, ITEM_TYPE, WORKSPACE, PLANNING, FEATURED_PLANNING} from '../constants'; -import {planning, events, assignments, autosave, main} from './index'; -import {lockUtils, getItemType, gettext, isExistingItem, modifyForClient} from '../utils'; -import {planningApi} from '../superdeskApi'; -import featuredPlanning from './planning/featuredPlanning'; - -/** - * Action Dispatcher to load all Event and Planning locks - * Then send them to the lock reducer for processing and storage - */ -const loadAllLocks = () => ( - (dispatch) => ( - Promise.all([ - planningApi.events.getLocked(), - planningApi.planning.getLocked(), - planningApi.planning.getLockedFeatured(), - ]) - .then((data) => { - const payload = { - events: data[0], - plans: data[1], - }; - - dispatch({ - type: LOCKS.ACTIONS.RECEIVE, - payload: payload, - }); - - // If featured stories are locked - if (get(data, '[2][0].lock_user')) { - dispatch(featuredPlanning.setLockUser( - data[2][0].lock_user, - data[2][0].lock_session - )); - } - - return Promise.resolve(payload); - }, (error) => Promise.reject(error)) - ) -); - -/** - * Action Dispatcher to load Assignment locks - * Then send them to the lock reducer for processing and storage - */ -const loadAssignmentLocks = () => ( - (dispatch) => ( - dispatch(assignments.api.queryLockedAssignments()) - .then((data) => { - const payload = {assignments: data}; - - dispatch({ - type: LOCKS.ACTIONS.RECEIVE, - payload: payload, - }); - return Promise.resolve(payload); - }, (error) => Promise.reject(error)) - ) -); - -/** - * Action Dispatcher to release the lock an a chain of Events and/or Planning items - * It retrieves the lock from the Redux store for the item provided - * and calls the appropriate unlock method on the item that is actually locked - * @param {object} item - The Event or Planning item chain to unlock - */ -const unlock = (item) => ( - (dispatch, getState, {notify}) => { - if (!isExistingItem(item)) { - if (get(item, '_planning_item')) { - dispatch(planning.api.unlock({_id: item._planning_item})); - } - - return dispatch(autosave.removeById(item.type, item._id)); - } - - const locks = selectors.locks.getLockedItems(getState()); - const currentLock = lockUtils.getLock(item, locks); - - if (currentLock === null) { - const errorMessage = gettext('Failed to unlock the item. Lock not found!'); - - notify.error(errorMessage); - return Promise.reject(errorMessage); - } - - let promise = Promise.resolve(item); - - switch (currentLock.item_type) { - case 'planning': - promise = dispatch(planning.api.unlock({_id: currentLock.item_id})); - break; - case 'event': - promise = dispatch(events.api.unlock({_id: currentLock.item_id})); - break; - } - - return promise; - } -); - -const lock = (item, lockAction = 'edit') => ( - (dispatch, getState, {notify}) => { - const itemType = getItemType(item); - const currentWorkspace = selectors.general.currentWorkspace(getState()); - - switch (itemType) { - case ITEM_TYPE.EVENT: - return dispatch(events.api.lock(item, lockAction)); - case ITEM_TYPE.PLANNING: - return dispatch(planning.api.lock( - item, - currentWorkspace === WORKSPACE.AUTHORING ? - PLANNING.ITEM_ACTIONS.ADD_TO_PLANNING.lock_action : - lockAction - )); - } - - const errorMessage = gettext('Failed to lock the item, could not determine item type!'); - - notify.error(errorMessage); - return Promise.reject(errorMessage); - } -); - -const unlockThenLock = (item, modal) => ( - (dispatch) => ( - dispatch(self.unlock(item)) - .then( - (unlockedItem) => ( - dispatch(main.openForEdit( - modifyForClient(item._id !== unlockedItem._id ? - item : - unlockedItem - ), true, modal - )) - ), - (error) => Promise.reject(error) - ) - ) -); - -// eslint-disable-next-line consistent-this -const self = { - lock, - unlock, - loadAllLocks, - loadAssignmentLocks, - unlockThenLock, -}; - -export default self; diff --git a/client/actions/main.ts b/client/actions/main.ts index 23da557d1..47ee02b9d 100644 --- a/client/actions/main.ts +++ b/client/actions/main.ts @@ -3,7 +3,7 @@ import moment from 'moment'; import {appConfig} from 'appConfig'; import {IUser} from 'superdesk-api'; -import {planningApi as planningApis, superdeskApi} from '../superdeskApi'; +import {planningApi, superdeskApi} from '../superdeskApi'; import { EDITOR_TYPE, ICombinedEventOrPlanningSearchParams, @@ -32,7 +32,7 @@ import { } from '../constants'; import {activeFilter, lastRequestParams} from '../selectors/main'; import planningUi from './planning/ui'; -import planningApi from './planning/api'; +import planningApis from './planning/api'; import eventsUi from './events/ui'; import eventsApi from './events/api'; import autosave from './autosave'; @@ -68,8 +68,8 @@ import * as selectors from '../selectors'; import {validateItem} from '../validators'; import {searchParamsToOld} from '../utils/search'; -const openForEdit = (item, updateUrl = true, modal = false) => ( - (dispatch, getState) => { +function openForEdit(item: IEventOrPlanningItem, updateUrl: boolean = true, modal: boolean = false) { + return (dispatch, getState) => { if (!isExistingItem(item)) { return dispatch( self.openEditorAction(item, 'create', updateUrl, modal) @@ -95,8 +95,8 @@ const openForEdit = (item, updateUrl = true, modal = false) => ( dispatch( self.openEditorAction(item, action, updateUrl, modal) ); - } -); + }; +} function openEditorAction( item: IEventOrPlanningItem, @@ -179,7 +179,9 @@ const createNew = (itemType, item = null, updateUrl = true, modal = false) => ( function createEventFromTemplate(template: IEventTemplate) { return self.createNew(ITEM_TYPE.EVENT, { ...template.data, - dates: {}, + dates: { + tz: template.data.dates?.tz + }, }); } @@ -199,7 +201,7 @@ const unlockAndCancel = (item, ignoreSession = false) => ( selectors.locks.getLockedItems(state), ignoreSession )) { - promise = dispatch(locks.unlock(item)); + promise = planningApi.locks.unlockItem(item); if (isExistingItem(item)) { promise.then( () => dispatch(autosave.removeById(itemType, itemId)) @@ -301,7 +303,7 @@ const save = (original, updates, withConfirmation = true) => ( break; case ITEM_TYPE.PLANNING: dispatch( - planningApi.receivePlannings([savedItem]) + planningApis.receivePlannings([savedItem]) ); break; } @@ -341,7 +343,7 @@ const unpost = (original, updates = {}, withConfirmation = true) => ( break; case ITEM_TYPE.PLANNING: confirmation = false; - promise = dispatch(planningApi.unpost(original, updates)); + promise = dispatch(planningApis.unpost(original, updates)); break; default: promise = Promise.reject( @@ -403,7 +405,7 @@ const post = (original, updates = {}, withConfirmation = true) => ( null, {}, original, - planningApi.post.bind(null, original, updates))); + planningApis.post.bind(null, original, updates))); break; default: promise = Promise.reject( @@ -529,7 +531,7 @@ const openActionModalFromEditor = (original, title, action) => ( // This helps to clear the Editor states before performing the action const unlockAndSetEditorReadOnly = (itemToUnlock) => ( Promise.all([ - dispatch(locks.unlock(itemToUnlock)), + planningApi.locks.unlockItem(itemToUnlock), (isOpenInEditor || isOpenInModal) ? dispatch(self.changeEditorAction('read', isOpenInModal)) : Promise.resolve(), @@ -624,6 +626,18 @@ const openActionModalFromEditor = (original, title, action) => ( } ); +interface IOpenIgnoreCancelSaveModalProps { + itemId: IEventOrPlanningItem['_id']; + itemType: IEventOrPlanningItem['type']; + onCancel?(): void; + onIgnore(): void; + onSave?(): void; + onGoTo(): void; + onSaveAndPost?(): void; + title?: string; + autoClose?: boolean; +} + const openIgnoreCancelSaveModal = ({ itemId, itemType, @@ -634,7 +648,7 @@ const openIgnoreCancelSaveModal = ({ onSaveAndPost, title, autoClose = true, -}) => ( +}: IOpenIgnoreCancelSaveModalProps) => ( (dispatch, getState) => { const autosaveData = getAutosaveItem( selectors.forms.autosaves(getState()), @@ -807,7 +821,7 @@ function _filter(filterType: PLANNING_VIEW, params: ICombinedEventOrPlanningSear const currentFilterId: ISearchFilter['_id'] = urlParams.getString('eventsPlanningFilter'); if (currentFilterId != undefined || filterType === PLANNING_VIEW.COMBINED) { - promise = planningApis.ui.list.changeFilterId(currentFilterId, params); + promise = planningApi.ui.list.changeFilterId(currentFilterId, params); } else if (filterType === PLANNING_VIEW.EVENTS) { const calendar = urlParams.getString('calendar') || lastParams?.calendars?.[0] || @@ -823,7 +837,7 @@ function _filter(filterType: PLANNING_VIEW, params: ICombinedEventOrPlanningSear EVENTS.FILTER.ALL_CALENDARS ); - promise = planningApis.ui.list.changeCalendarId( + promise = planningApi.ui.list.changeCalendarId( calender, params ); @@ -835,7 +849,7 @@ function _filter(filterType: PLANNING_VIEW, params: ICombinedEventOrPlanningSear AGENDA.FILTER.ALL_PLANNING ); - promise = planningApis.ui.list.changeAgendaId( + promise = planningApi.ui.list.changeAgendaId( searchAgenda, params ); @@ -997,7 +1011,7 @@ const closeEditor = (modal = false) => ( payload: modal, }); - planningApis.editor(modal ? EDITOR_TYPE.POPUP : EDITOR_TYPE.INLINE) + planningApi.editor(modal ? EDITOR_TYPE.POPUP : EDITOR_TYPE.INLINE) .events.onEditorClosed(); if (!modal) { @@ -1078,7 +1092,7 @@ const fetchById = (itemId, itemType, force = false) => ( if (itemType === ITEM_TYPE.EVENT) { return dispatch(eventsApi.fetchById(itemId, {force})); } else if (itemType === ITEM_TYPE.PLANNING) { - return dispatch(planningApi.fetchById(itemId, {force})); + return dispatch(planningApis.fetchById(itemId, {force})); } } @@ -1281,7 +1295,7 @@ const fetchItemHistory = (item) => ( historyDispatch = eventsApi.fetchEventHistory; break; case ITEM_TYPE.PLANNING: - historyDispatch = planningApi.fetchPlanningHistory; + historyDispatch = planningApis.fetchPlanningHistory; break; } @@ -1365,14 +1379,17 @@ function onItemUnlocked( itemType: ITEM_TYPE, ) { return (dispatch, getState) => { - const lockedItems = selectors.locks.getLockedItems(getState()); + const state = getState(); + const lockedItems = selectors.locks.getLockedItems(state); const itemLock = lockUtils.getLock(item, lockedItems); - const sessionId = selectors.general.session(getState()).sessionId; + const sessionId = selectors.general.session(state).sessionId; - const editorItemId = selectors.forms.currentItemId(getState()); - const editorModalItemId = selectors.forms.currentItemIdModal(getState()); + const editorItemId = selectors.forms.currentItemId(state); + const editorModalItemId = selectors.forms.currentItemIdModal(state); const itemId = getItemId(item); + planningApi.locks.setItemAsUnlocked(data); + if (editorItemId === itemId || editorModalItemId === itemId) { dispatch(self.changeEditorAction( 'read', @@ -1385,14 +1402,19 @@ function onItemUnlocked( data.lock_session !== sessionId && itemLock.session === sessionId ) { - const user = selectors.general.users(getState()).find((u) => u._id === data.user); - const autoSaves = selectors.forms.autosaves(getState()); + const user = selectors.general.users(state).find((u) => u._id === data.user); + const autoSaves = selectors.forms.autosaves(state); const autoSaveInStore = get(autoSaves, `${itemType}['${data.item}']`); if (autoSaveInStore) { // Delete the changes from the local redux dispatch(autosave.removeLocalAutosave(autoSaveInStore)); } + if (selectors.general.getActionModalItemId(state) === item._id) { + // This item has an action modal open, such as 'Spike ' + // Close it now, so the 'Item Unlocked' Modal will be the only one in the Modal stack + dispatch(hideModal()); + } dispatch(showModal({ modalType: MODALS.NOTIFICATION_MODAL, @@ -1402,7 +1424,7 @@ function onItemUnlocked( }, })); - if (getItemType(item) === ITEM_TYPE.PLANNING && selectors.general.currentWorkspace(getState()) + if (getItemType(item) === ITEM_TYPE.PLANNING && selectors.general.currentWorkspace(state) === WORKSPACE.AUTHORING) { dispatch(self.closePreviewAndEditorForItems([item])); } @@ -1451,7 +1473,7 @@ const fetchQueueItem = (item) => ( dispatch(eventsApi.receiveEvents([publishedItem])); } else { planningUtils.modifyForClient(publishedItem); - dispatch(planningApi.receivePlannings([publishedItem])); + dispatch(planningApis.receivePlannings([publishedItem])); } } return Promise.resolve(publishedItem); @@ -1522,13 +1544,17 @@ const spikeAfterUnlock = (unlockedItem, previousLock, openInEditor, openInModal) (dispatch) => { const onCloseModal = (updatedItem) => { if (!isItemSpiked(updatedItem) && get(previousLock, 'action')) { - if (openInEditor || openInModal) { - return dispatch( - self.openForEdit(updatedItem, !openInModal, openInModal) - ); - } - - return dispatch(locks.lock(updatedItem, previousLock.action)); + planningApi.locks.lockItem(updatedItem, previousLock.action) + .then((lockedItem) => { + if (openInEditor || openInModal) { + dispatch(self.openEditorAction( + lockedItem, + 'edit', + true, + openInModal + )); + } + }); } }; const dispatchCall = getItemType(unlockedItem) === ITEM_TYPE.PLANNING ? @@ -1569,12 +1595,12 @@ const saveAndUnlockItem = (original, updates, ignoreRecurring = false) => ( break; case ITEM_TYPE.PLANNING: dispatch( - planningApi.receivePlannings([savedItem]) + planningApis.receivePlannings([savedItem]) ); break; } - return dispatch(locks.unlock(get(savedItem, '[0]', savedItem))) + return planningApi.locks.unlockItem(get(savedItem, '[0]', savedItem)) .then((unlockedItem) => Promise.resolve(unlockedItem)) .catch(() => { notify.error(gettext('Could not unlock the item.')); diff --git a/client/actions/multiSelect.ts b/client/actions/multiSelect.ts index dcc39b6eb..834de3dd6 100644 --- a/client/actions/multiSelect.ts +++ b/client/actions/multiSelect.ts @@ -7,7 +7,7 @@ import {showModal} from './index'; import {MULTISELECT, ITEM_TYPE, MODALS} from '../constants'; import eventsUi from './events/ui'; import planningUi from './planning/ui'; -import {getItemType, gettext, planningUtils, eventUtils, getItemInArrayById, getErrorMessage} from '../utils'; +import {getItemType, gettext, getItemInArrayById, getErrorMessage, lockUtils} from '../utils'; /** * Action Dispatcher to select an/all Event(s) @@ -193,11 +193,9 @@ const exportAsArticle = (items = [], download) => ( const sortableItems = []; const label = (item) => item.headline || item.slugline || item.description_text || item.name; const locks = selectors.locks.getLockedItems(state); - const isLockedCheck = isPlanning ? planningUtils.isPlanningLocked : - eventUtils.isEventLocked; items.forEach((item) => { - const isLocked = isLockedCheck(item, locks); + const isLocked = lockUtils.isItemLocked(item, locks); const isNotForPublication = get(item, 'flags.marked_for_not_publication'); if (isLocked || isNotForPublication) { diff --git a/client/actions/planning/api.ts b/client/actions/planning/api.ts index 798bf661f..3fa242e1c 100644 --- a/client/actions/planning/api.ts +++ b/client/actions/planning/api.ts @@ -2,14 +2,13 @@ import {get, cloneDeep, pickBy, has, every} from 'lodash'; import {IEventItem, IPlanningSearchParams, IPlanningItem} from '../../interfaces'; import {appConfig} from 'appConfig'; +import {planningApi} from '../../superdeskApi'; import * as actions from '../../actions'; import * as selectors from '../../selectors'; import { getErrorMessage, - getTimeZoneOffset, planningUtils, - lockUtils, isExistingItem, isPublishedItemId, isValidFileInput, @@ -24,7 +23,6 @@ import { TO_BE_CONFIRMED_FIELD, } from '../../constants'; import main from '../main'; -import {planningApi} from '../../superdeskApi'; import {planningParamsToSearchParams} from '../../utils/search'; /** @@ -498,77 +496,6 @@ const receivePlannings = (plannings) => ( } ); -/** - * Action dispatcher that attempts to unlock a Planning item through the API - * @param {object} item - The Planning item to unlock - * @return Promise - */ -const unlock = (item) => ( - (dispatch, getState, {api}) => ( - api('planning_unlock', item).save({}) - ) - .then((item) => { - planningUtils.modifyForClient(item); - - dispatch({ - type: PLANNING.ACTIONS.UNLOCK_PLANNING, - payload: {plan: item}, - }); - - return Promise.resolve(item); - }, (error) => Promise.reject(error)) -); - -/** - * Action dispatcher that attempts to lock a Planning item through the API - * @param {object} planning - The Planning item to lock - * @param {String} lockAction - The lock action - * @return Promise - */ -const lock = (planning, lockAction = 'edit') => ( - (dispatch, getState, {api}) => { - if (lockAction === null || - lockUtils.isItemLockedInThisSession( - planning, - selectors.general.session(getState()), - selectors.locks.getLockedItems(getState()) - ) - ) { - return Promise.resolve(planning); - } - - return api('planning_lock', planning).save({}, {lock_action: lockAction}) - .then((item) => { - planningUtils.modifyForClient(item); - - dispatch({ - type: PLANNING.ACTIONS.LOCK_PLANNING, - payload: {plan: item}, - }); - - return Promise.resolve(item); - }, (error) => Promise.reject(error)); - } -); - -/** - * Locks featured stories action - * @return Promise - */ -function lockFeaturedPlanning() { - return (dispatch, getState, {notify}) => ( - planningApi.planning.featured.lock() - .catch((error) => { - notify.error( - getErrorMessage( - error, - gettext('Failed to lock featured story action!') - ) - ); - }) - ); -} - const fetchPlanningFiles = (planning) => ( (dispatch, getState) => { if (!planningUtils.shouldFetchFilesForPlanning(planning)) { @@ -605,36 +532,6 @@ const getFiles = (files) => ( ) ); - -/** - * Action dispatcher to save the featured planning record through the API - * @param {object} updates - updates to save - * @return Promise - */ -const saveFeaturedPlanning = (updates) => ( - (dispatch, getState, {api}) => { - const item = selectors.featuredPlanning.featuredPlanningItem(getState()) || {}; - - return api('planning_featured').save(cloneDeep(item), {...updates}) - .then((savedItem) => savedItem); - } -); - - -/** - * Unlocks featured planning action - * @return Promise - */ -function unlockFeaturedPlanning() { - return (dispatch, getState, {notify}) => ( - planningApi.planning.featured.unlock() - .catch((error) => { - notify.error( - getErrorMessage(error, gettext('Failed to unlock featured story action!'))); - }) - ); -} - const markPlanningCancelled = (plan, reason, coverageState, eventCancellation) => ({ type: PLANNING.ACTIONS.MARK_PLANNING_CANCELLED, payload: { @@ -732,8 +629,6 @@ const self = { save, fetchById, fetchPlanningsEvents, - unlock, - lock, loadPlanningById, loadPlanningByIds, fetchPlanningHistory, @@ -750,9 +645,6 @@ const self = { loadPlanningByRecurrenceId, cancel, cancelAllCoverage, - lockFeaturedPlanning, - unlockFeaturedPlanning, - saveFeaturedPlanning, fetchPlanningFiles, uploadFiles, removeFile, diff --git a/client/actions/planning/featuredPlanning.ts b/client/actions/planning/featuredPlanning.ts index cc325d849..798766402 100644 --- a/client/actions/planning/featuredPlanning.ts +++ b/client/actions/planning/featuredPlanning.ts @@ -4,9 +4,7 @@ import {cloneDeep, some} from 'lodash'; import {appConfig} from 'appConfig'; import {IUser} from 'superdesk-api'; import {IFeaturedPlanningItem, IFeaturedPlanningSaveItem, IPlanningItem, ISearchParams} from '../../interfaces'; -import {planningApi as planningApis, superdeskApi} from '../../superdeskApi'; -import planningApi from './api'; -import {locks} from '../index'; +import {planningApi, superdeskApi} from '../../superdeskApi'; import main from '../main'; import {MODALS, FEATURED_PLANNING, TIME_COMPARISON_GRANULARITY} from '../../constants'; @@ -134,7 +132,7 @@ function movePlanningToUnselectedList(item: IPlanningItem) { function getAndUpdateStoredPlanningItem(itemId: IPlanningItem['_id']) { return (dispatch, getState) => { if (selectors.featuredPlanning.inUse(getState())) { - planningApis.planning.getById(itemId, false, true).then((item) => { + planningApi.planning.getById(itemId, false, true).then((item) => { dispatch({ type: FEATURED_PLANNING.ACTIONS.UPDATE_PLANNING_AND_LISTS, payload: item, @@ -147,7 +145,7 @@ function getAndUpdateStoredPlanningItem(itemId: IPlanningItem['_id']) { function updatePlanningMetadata(itemId: IPlanningItem['_id']) { return (dispatch, getState) => { if (selectors.featuredPlanning.inUse(getState())) { - planningApis.planning.getById(itemId, false, true).then((item) => { + planningApi.planning.getById(itemId, false, true).then((item) => { dispatch({ type: FEATURED_PLANNING.ACTIONS.UPDATE_PLANNING_METADATA, payload: item, @@ -159,7 +157,7 @@ function updatePlanningMetadata(itemId: IPlanningItem['_id']) { function getFeaturedPlanningItem(date: moment.Moment) { return (dispatch) => ( - planningApis.planning.featured.getByDate(date) + planningApi.planning.featured.getByDate(date) .then((item) => { dispatch(setFeaturedPlanningItem(item)); @@ -173,10 +171,10 @@ function fetchToList(params: ISearchParams = {}, featuredItem?: IFeaturedPlannin dispatch(setCurrentSearchParams(params)); return (featuredItem?.items?.length ? - planningApis.planning.getByIds(featuredItem?.items, 'both', {include_killed: true}) : + planningApi.planning.getByIds(featuredItem?.items, 'both', {include_killed: true}) : Promise.resolve>([]) ).then((currentFeaturedItems) => ( - planningApis.planning.searchGetAll(params) + planningApi.planning.searchGetAll(params) .then((searchResults) => ({ current: currentFeaturedItems, search: searchResults, @@ -265,7 +263,7 @@ function openFeaturedPlanningModal() { dispatch(setInUse(true)); dispatch(showModal({modalType: MODALS.FEATURED_STORIES})); - dispatch(planningApi.lockFeaturedPlanning()) + planningApi.locks.lockFeaturedPlanning() .then(() => ( dispatch(loadFeaturedPlanningsData(currentSearchDate)) )) @@ -291,7 +289,7 @@ function modifyPlanningFeatured(original: IPlanningItem, remove: boolean = false dispatch(_modifyPlanningFeatured(unlockedItem, remove)) .then((updatedItem) => { if (previousLock?.action) { - dispatch(locks.lock(updatedItem, previousLock.action)) + planningApi.locks.lockItem(updatedItem, previousLock.action) .then((updatedUnlockedItem) => { if (openInEditor || openInModal) { dispatch(main.openForEdit(updatedUnlockedItem, !openInModal, openInModal)); @@ -306,7 +304,7 @@ function modifyPlanningFeatured(original: IPlanningItem, remove: boolean = false function _modifyPlanningFeatured(item: IPlanningItem, remove: boolean = false) { return (dispatch) => ( - dispatch(locks.lock(item, remove ? 'remove_featured' : 'add_featured')) + planningApi.locks.lockItem(item, remove ? 'remove_featured' : 'add_featured') .then((original: IPlanningItem) => { const updates = cloneDeep(original); const {gettext} = superdeskApi.localization; @@ -334,12 +332,12 @@ function _modifyPlanningFeatured(item: IPlanningItem, remove: boolean = false) { ); } -function saveFeaturedPlanningForDate(updates: IFeaturedPlanningSaveItem, reloadFeaturedItem: boolean) { +function saveFeaturedPlanningForDate(updates: Partial, reloadFeaturedItem: boolean) { const {gettext} = superdeskApi.localization; const {notify} = superdeskApi.ui; return (dispatch, getState) => ( - dispatch(planningApi.saveFeaturedPlanning(updates)) + planningApi.planning.featured.save(updates) .then( (item: IFeaturedPlanningItem) => { if (item.posted) { @@ -366,7 +364,7 @@ function unsetFeaturePlanningInUse(unlock: boolean = true) { dispatch(setInUse(false)); if (unlock) { - return dispatch(planningApi.unlockFeaturedPlanning()) + planningApi.locks.unlockFeaturedPlanning() .then(() => { dispatch(hideModal()); return Promise.resolve(); @@ -379,13 +377,12 @@ function unsetFeaturePlanningInUse(unlock: boolean = true) { function forceUnlock() { return (dispatch) => ( - dispatch(planningApi.unlockFeaturedPlanning()) - .then(() => { + planningApi.locks.unlockFeaturedPlanning() + .then(() => ( // Set unlocked here so the websocket notification doesn't think // the current session is getting unlocked by another user/session - dispatch(self.setUnlocked()); - return dispatch(self.openFeaturedPlanningModal()); - }) + dispatch(self.openFeaturedPlanningModal()) + )) ); } diff --git a/client/actions/planning/notifications.ts b/client/actions/planning/notifications.ts index e25863db1..cfae83f1b 100644 --- a/client/actions/planning/notifications.ts +++ b/client/actions/planning/notifications.ts @@ -1,12 +1,17 @@ import {get} from 'lodash'; + import {IWebsocketMessageData, ITEM_TYPE} from '../../interfaces'; +import {planningApi} from '../../superdeskApi'; + +import {gettext, lockUtils} from '../../utils'; +import {PLANNING, MODALS, WORKFLOW_STATE, WORKSPACE} from '../../constants'; + import planning from './index'; import assignments from '../assignments/index'; -import {gettext, lockUtils} from '../../utils'; + import * as selectors from '../../selectors'; import {events, fetchAgendas} from '../index'; import main from '../main'; -import {PLANNING, MODALS, WORKFLOW_STATE, WORKSPACE} from '../../constants'; import {showModal, hideModal} from '../index'; import eventsPlanning from '../eventsPlanning'; @@ -95,6 +100,8 @@ const onPlanningUpdated = (_e, data) => ( const onPlanningLocked = (e, data) => ( (dispatch, getState) => { if (get(data, 'item')) { + planningApi.locks.setItemAsLocked(data); + const sessionId = selectors.general.session(getState()).sessionId; return dispatch(planning.api.getPlanning(data.item, false)) @@ -143,13 +150,13 @@ function onPlanningUnlocked(_e: {}, data: IWebsocketMessageData['ITEM_UNLOCKED'] let planningItem = selectors.planning.storedPlannings(state)[data.item]; const isCurrentlyLocked = lockUtils.isItemLocked(planningItem, selectors.locks.getLockedItems(state)); + dispatch(main.onItemUnlocked(data, planningItem, ITEM_TYPE.PLANNING)); + if (!isCurrentlyLocked && planningItem?.lock_session == null) { // No need to announce an unlock, as we have already done so return Promise.resolve(); } - dispatch(main.onItemUnlocked(data, planningItem, ITEM_TYPE.PLANNING)); - planningItem = { event_item: get(data, 'event_item') || null, recurrence_id: get(data, 'recurrence_id') || null, diff --git a/client/actions/planning/tests/api_test.ts b/client/actions/planning/tests/api_test.ts index 8e52bacc0..a3d48acfa 100644 --- a/client/actions/planning/tests/api_test.ts +++ b/client/actions/planning/tests/api_test.ts @@ -672,101 +672,4 @@ describe('actions.planning.api', () => { .catch(done.fail); }); }); - - describe('lock/unlock', () => { - let mockStore; - let mocks; - let getLocks = () => selectors.locks.getLockedItems(mockStore.getState()); - - beforeEach(() => { - mocks = { - api: sinon.spy(() => mocks), - save: sinon.spy((original, updates = {}) => Promise.resolve({ - ...data.plannings[0], - ...updates, - })), - }; - - store.init(); - }); - - it('calls lock endpoint and updates the redux store', (done) => { - mockStore = createTestStore({ - initialState: store.initialState, - extraArguments: { - api: mocks.api, - }, - }); - - expect(getLocks().planning).toEqual({}); - - mockStore.dispatch(planningApi.lock(data.plannings[0])) - .then(() => { - expect(mocks.api.callCount).toBe(1); - expect(mocks.api.args[0]).toEqual([ - 'planning_lock', - data.plannings[0], - ]); - - expect(mocks.save.callCount).toBe(1); - expect(mocks.save.args[0]).toEqual([ - {}, - {lock_action: 'edit'}, - ]); - - expect(getLocks().planning).toEqual({ - p1: jasmine.objectContaining({ - action: 'edit', - item_type: 'planning', - item_id: 'p1', - }), - }); - - done(); - }) - .catch(done.fail); - }); - - it('calls unlock endpoint and updates the redux store', (done) => { - store.initialState.locks.planning = { - p1: { - action: 'edit', - item_type: 'planning', - item_id: 'p1', - }, - }; - - mockStore = createTestStore({ - initialState: store.initialState, - extraArguments: { - api: mocks.api, - }, - }); - - expect(getLocks().planning).toEqual({ - p1: jasmine.objectContaining({ - action: 'edit', - item_type: 'planning', - item_id: 'p1', - }), - }); - - mockStore.dispatch(planningApi.unlock(data.plannings[0])) - .then(() => { - expect(mocks.api.callCount).toBe(1); - expect(mocks.api.args[0]).toEqual([ - 'planning_unlock', - data.plannings[0], - ]); - - expect(mocks.save.callCount).toBe(1); - expect(mocks.save.args[0]).toEqual([{}]); - - expect(getLocks().planning).toEqual({}); - - done(); - }) - .catch(done.fail); - }); - }); }); diff --git a/client/actions/planning/tests/notifications_test.ts b/client/actions/planning/tests/notifications_test.ts index b0fbb7c19..79fcfbff2 100644 --- a/client/actions/planning/tests/notifications_test.ts +++ b/client/actions/planning/tests/notifications_test.ts @@ -1,4 +1,5 @@ -import planningApi from '../api'; +import {planningApi} from '../../../superdeskApi'; +import planningApis from '../api'; import planningUi from '../ui'; import featuredPlanning from '../featuredPlanning'; import eventsPlanningUi from '../../eventsPlanning/ui'; @@ -195,11 +196,13 @@ describe('actions.planning.notifications', () => { describe('onPlanningLocked', () => { beforeEach(() => { - sinon.stub(planningApi, 'getPlanning').returns(Promise.resolve(data.plannings[0])); + sinon.stub(planningApi.locks, 'setItemAsLocked').returns(undefined); + sinon.stub(planningApis, 'getPlanning').returns(Promise.resolve(data.plannings[0])); }); afterEach(() => { - restoreSinonStub(planningApi.getPlanning); + restoreSinonStub(planningApi.locks.setItemAsLocked); + restoreSinonStub(planningApis.getPlanning); }); it('calls getPlanning and dispatches the LOCK_PLANNING action', (done) => ( @@ -215,8 +218,9 @@ describe('actions.planning.notifications', () => { } )) .then(() => { - expect(planningApi.getPlanning.callCount).toBe(1); - expect(planningApi.getPlanning.args[0]).toEqual([ + expect(planningApi.locks.setItemAsLocked.callCount).toBe(1); + expect(planningApis.getPlanning.callCount).toBe(1); + expect(planningApis.getPlanning.args[0]).toEqual([ 'p1', false, ]); @@ -245,10 +249,12 @@ describe('actions.planning.notifications', () => { store.initialState.planning.plannings.p1.lock_user = 'ident1'; store.initialState.planning.plannings.p1.lock_session = 'session1'; store.initialState.planning.plannings.p1.lock_time = '2022-06-15T13:01:11+0000'; + sinon.stub(planningApi.locks, 'setItemAsUnlocked').returns(undefined); sinon.stub(main, 'changeEditorAction').callsFake(() => Promise.resolve()); }); afterEach(() => { + restoreSinonStub(planningApi.locks.setItemAsUnlocked); restoreSinonStub(main.changeEditorAction); }); @@ -278,6 +284,7 @@ describe('actions.planning.notifications', () => { user: 'ident2', })) .then(() => { + expect(planningApi.locks.setItemAsUnlocked.callCount).toBe(1); const modalStr = 'The Planning item you were editing was unlocked by "{{ userName }}"'; expect(store.dispatch.args[2][0].type).toEqual('AUTOSAVE_REMOVE'); @@ -305,6 +312,7 @@ describe('actions.planning.notifications', () => { etag: 'e123', })) .then(() => { + expect(planningApi.locks.setItemAsUnlocked.callCount).toBe(1); expect(store.dispatch.args[1]).toEqual([{ type: 'UNLOCK_PLANNING', payload: { diff --git a/client/actions/planning/tests/ui_test.ts b/client/actions/planning/tests/ui_test.ts index f9619749c..a9534a8f1 100644 --- a/client/actions/planning/tests/ui_test.ts +++ b/client/actions/planning/tests/ui_test.ts @@ -1,7 +1,8 @@ +import {planningApi} from '../../../superdeskApi'; import planningUi from '../ui'; -import planningApi from '../api'; +import planningApis from '../api'; import assignmentApi from '../../assignments/api'; -import {main, locks} from '../../'; +import {main} from '../../'; import sinon from 'sinon'; import {MAIN, WORKSPACE} from '../../../constants'; import {getTestActionStore, restoreSinonStub} from '../../../utils/testUtils'; @@ -19,13 +20,13 @@ describe('actions.planning.ui', () => { services = store.services; data = store.data; - sinon.stub(planningApi, 'spike').callsFake(() => (Promise.resolve())); - sinon.stub(planningApi, 'unspike').callsFake(() => (Promise.resolve())); - sinon.stub(planningApi, 'fetch').callsFake(() => (Promise.resolve())); - sinon.stub(planningApi, 'refetch').callsFake(() => (Promise.resolve())); - sinon.stub(planningApi, 'save').callsFake((item) => (Promise.resolve(item))); - sinon.stub(planningApi, 'lock').callsFake((item) => (Promise.resolve(item))); - sinon.stub(planningApi, 'unlock').callsFake(() => (Promise.resolve(data.plannings[0]))); + sinon.stub(planningApis, 'spike').callsFake(() => (Promise.resolve())); + sinon.stub(planningApis, 'unspike').callsFake(() => (Promise.resolve())); + sinon.stub(planningApis, 'fetch').callsFake(() => (Promise.resolve())); + sinon.stub(planningApis, 'refetch').callsFake(() => (Promise.resolve())); + sinon.stub(planningApis, 'save').callsFake((item) => (Promise.resolve(item))); + sinon.stub(planningApi.locks, 'lockItem').callsFake((item) => Promise.resolve(item)); + sinon.stub(planningApi.locks, 'unlockItem').callsFake((item) => Promise.resolve(item)); sinon.stub(planningUi, 'requestPlannings').callsFake(() => (Promise.resolve())); sinon.stub(planningUi, 'clearList').callsFake(() => ({type: 'clearList'})); @@ -39,18 +40,16 @@ describe('actions.planning.ui', () => { sinon.stub(main, 'closePreviewAndEditorForItems').callsFake(() => (Promise.resolve())); sinon.stub(main, 'openForEdit'); - sinon.stub(locks, 'lock').callsFake((item) => (Promise.resolve(item))); }); afterEach(() => { - restoreSinonStub(planningApi.spike); - restoreSinonStub(planningApi.unspike); - restoreSinonStub(planningApi.fetch); - restoreSinonStub(planningApi.refetch); - restoreSinonStub(planningApi.save); - restoreSinonStub(planningApi.lock); - restoreSinonStub(planningApi.unlock); - + restoreSinonStub(planningApis.spike); + restoreSinonStub(planningApis.unspike); + restoreSinonStub(planningApis.fetch); + restoreSinonStub(planningApis.refetch); + restoreSinonStub(planningApis.save); + restoreSinonStub(planningApi.locks.lockItem); + restoreSinonStub(planningApi.locks.unlockItem); restoreSinonStub(planningUi.requestPlannings); restoreSinonStub(planningUi.clearList); restoreSinonStub(planningUi.setInList); @@ -63,12 +62,11 @@ describe('actions.planning.ui', () => { restoreSinonStub(main.closePreviewAndEditorForItems); restoreSinonStub(main.openForEdit); - restoreSinonStub(locks.lock); }); describe('spike', () => { afterEach(() => { - restoreSinonStub(planningApi.refetch); + restoreSinonStub(planningApis.refetch); restoreSinonStub(planningUi.refetch); }); @@ -78,8 +76,8 @@ describe('actions.planning.ui', () => { expect(item).toEqual(data.plannings[1]); // Calls api.spike - expect(planningApi.spike.callCount).toBe(1); - expect(planningApi.spike.args[0]).toEqual([data.plannings[1]]); + expect(planningApis.spike.callCount).toBe(1); + expect(planningApis.spike.args[0]).toEqual([data.plannings[1]]); // Notifies end user of success expect(services.notify.success.callCount).toBe(1); @@ -104,8 +102,8 @@ describe('actions.planning.ui', () => { }); it('ui.spike notifies end user on failure to spike', (done) => { - restoreSinonStub(planningApi.spike); - sinon.stub(planningApi, 'spike').callsFake(() => (Promise.reject(errorMessage))); + restoreSinonStub(planningApis.spike); + sinon.stub(planningApis, 'spike').callsFake(() => (Promise.reject(errorMessage))); return store.test(done, planningUi.spike(data.plannings[1])) .then(() => { /* no-op */ }, (error) => { expect(error).toEqual(errorMessage); @@ -132,8 +130,8 @@ describe('actions.planning.ui', () => { expect(item).toEqual(data.plannings[1]); // Calls api.unspike - expect(planningApi.unspike.callCount).toBe(1); - expect(planningApi.unspike.args[0]).toEqual([data.plannings[1]]); + expect(planningApis.unspike.callCount).toBe(1); + expect(planningApis.unspike.args[0]).toEqual([data.plannings[1]]); // Notified end user of success expect(services.notify.success.callCount).toBe(1); @@ -148,8 +146,8 @@ describe('actions.planning.ui', () => { ).catch(done.fail)); it('ui.unspike notifies end user on failure to unspike', (done) => { - restoreSinonStub(planningApi.unspike); - sinon.stub(planningApi, 'unspike').callsFake(() => (Promise.reject(errorMessage))); + restoreSinonStub(planningApis.unspike); + sinon.stub(planningApis, 'unspike').callsFake(() => (Promise.reject(errorMessage))); return store.test(done, planningUi.unspike(data.plannings[1])) .then(() => { /* no-op */ }, (error) => { expect(error).toEqual(errorMessage); @@ -174,8 +172,8 @@ describe('actions.planning.ui', () => { .then((item) => { expect(item).toEqual(data.plannings[1]); - expect(planningApi.save.callCount).toBe(1); - expect(planningApi.save.args[0]).toEqual([ + expect(planningApis.save.callCount).toBe(1); + expect(planningApis.save.args[0]).toEqual([ data.plannings[1], {slugline: 'New Slugger'}, ]); @@ -186,8 +184,8 @@ describe('actions.planning.ui', () => { ); it('on save fail notifies the end user', (done) => { - restoreSinonStub(planningApi.save); - sinon.stub(planningApi, 'save').callsFake( + restoreSinonStub(planningApis.save); + sinon.stub(planningApis, 'save').callsFake( () => (Promise.reject(errorMessage)) ); @@ -232,8 +230,8 @@ describe('actions.planning.ui', () => { it('fetchToList', (done) => { restoreSinonStub(planningUi.fetchToList); - restoreSinonStub(planningApi.fetch); - sinon.stub(planningApi, 'fetch').callsFake( + restoreSinonStub(planningApis.fetch); + sinon.stub(planningApis, 'fetch').callsFake( () => (Promise.resolve(data.plannings)) ); @@ -244,8 +242,8 @@ describe('actions.planning.ui', () => { expect(planningUi.requestPlannings.callCount).toBe(1); expect(planningUi.requestPlannings.args[0]).toEqual([params]); - expect(planningApi.fetch.callCount).toBe(1); - expect(planningApi.fetch.args[0]).toEqual([params]); + expect(planningApis.fetch.callCount).toBe(1); + expect(planningApis.fetch.args[0]).toEqual([params]); expect(planningUi.setInList.callCount).toBe(1); expect(planningUi.setInList.args[0]).toEqual([['p1', 'p2']]); @@ -265,8 +263,8 @@ describe('actions.planning.ui', () => { }; restoreSinonStub(planningUi.loadMore); - restoreSinonStub(planningApi.fetch); - sinon.stub(planningApi, 'fetch').callsFake( + restoreSinonStub(planningApis.fetch); + sinon.stub(planningApis, 'fetch').callsFake( () => (Promise.resolve(data.plannings)) ); @@ -280,8 +278,8 @@ describe('actions.planning.ui', () => { .then(() => { expect(planningUi.requestPlannings.callCount).toBe(0); - expect(planningApi.fetch.callCount).toBe(1); - expect(planningApi.fetch.args[0]).toEqual([expectedParams]); + expect(planningApis.fetch.callCount).toBe(1); + expect(planningApis.fetch.args[0]).toEqual([expectedParams]); expect(planningUi.addToList.callCount).toBe(1); expect(planningUi.addToList.args[0]).toEqual([['p1', 'p2']]); @@ -301,8 +299,8 @@ describe('actions.planning.ui', () => { }; restoreSinonStub(planningUi.loadMore); - restoreSinonStub(planningApi.fetch); - sinon.stub(planningApi, 'fetch').callsFake( + restoreSinonStub(planningApis.fetch); + sinon.stub(planningApis, 'fetch').callsFake( () => (Promise.resolve(Array.from(Array(MAIN.PAGE_SIZE).keys()))) ); @@ -317,8 +315,8 @@ describe('actions.planning.ui', () => { expect(planningUi.requestPlannings.callCount).toBe(1); expect(planningUi.requestPlannings.args[0]).toEqual([expectedParams]); - expect(planningApi.fetch.callCount).toBe(1); - expect(planningApi.fetch.args[0]).toEqual([expectedParams]); + expect(planningApis.fetch.callCount).toBe(1); + expect(planningApis.fetch.args[0]).toEqual([expectedParams]); expect(planningUi.addToList.callCount).toBe(1); @@ -352,8 +350,6 @@ describe('actions.planning.ui', () => { modalType: 'ADD_TO_PLANNING', modalProps: {newsItem}, }; - - sinon.stub(locks, 'unlock').callsFake((item) => (Promise.resolve(item))); }); it('unlocks current planning opens the new planning', (done) => { @@ -363,8 +359,8 @@ describe('actions.planning.ui', () => { store.initialState.planning.plannings.p1 )) .then(() => { - expect(locks.unlock.callCount).toBe(1); - expect(locks.unlock.args[0]).toEqual([ + expect(planningApi.locks.unlockItem.callCount).toBe(1); + expect(planningApi.locks.unlockItem.args[0]).toEqual([ store.initialState.planning.plannings.p2, ]); @@ -374,10 +370,6 @@ describe('actions.planning.ui', () => { }) .catch(done.fail); }); - - afterEach(() => { - restoreSinonStub(locks.unlock); - }); }); describe('saveFromAuthoring', () => { @@ -414,16 +406,16 @@ describe('actions.planning.ui', () => { {...data.plannings[0], slugline: 'New Slugger'} )); - expect(planningApi.save.callCount).toBe(1); - expect(planningApi.save.args[0]).toEqual([ + expect(planningApis.save.callCount).toBe(1); + expect(planningApis.save.args[0]).toEqual([ data.plannings[0], {...data.plannings[0], slugline: 'New Slugger'}, ]); }); it('notifies user if save fails', (done) => { - restoreSinonStub(planningApi.save); - sinon.stub(planningApi, 'save').callsFake(() => Promise.reject(errorMessage)); + restoreSinonStub(planningApis.save); + sinon.stub(planningApis, 'save').callsFake(() => Promise.reject(errorMessage)); store.test(done, planningUi.saveFromAuthoring(data.plannings[0])) .then(() => { /* no-op */ }, () => { @@ -463,8 +455,8 @@ describe('actions.planning.ui', () => { {...data.plannings[0], slugline: 'New Slugger'} )) .then(() => { - expect(planningApi.save.callCount).toBe(1); - expect(planningApi.save.args[0]).toEqual([ + expect(planningApis.save.callCount).toBe(1); + expect(planningApis.save.args[0]).toEqual([ data.plannings[0], {...data.plannings[0], slugline: 'New Slugger'}, ]); @@ -502,17 +494,17 @@ describe('actions.planning.ui', () => { describe('duplicate', () => { afterEach(() => { - restoreSinonStub(planningApi.duplicate); + restoreSinonStub(planningApis.duplicate); }); it('duplicate calls planning.api.duplicate and notifies the user of success', (done) => { - sinon.stub(planningApi, 'duplicate').callsFake((item) => Promise.resolve(item)); + sinon.stub(planningApis, 'duplicate').callsFake((item) => Promise.resolve(item)); store.test(done, planningUi.duplicate(data.plannings[0])) .then((item) => { expect(item).toEqual(data.plannings[0]); - expect(planningApi.duplicate.callCount).toBe(1); - expect(planningApi.duplicate.args[0]).toEqual([data.plannings[0]]); + expect(planningApis.duplicate.callCount).toBe(1); + expect(planningApis.duplicate.args[0]).toEqual([data.plannings[0]]); expect(services.notify.error.callCount).toBe(0); expect(services.notify.success.callCount).toBe(1); @@ -527,7 +519,7 @@ describe('actions.planning.ui', () => { }); it('on duplicate error notify the user of the failure', (done) => { - sinon.stub(planningApi, 'duplicate').callsFake(() => Promise.reject(errorMessage)); + sinon.stub(planningApis, 'duplicate').callsFake(() => Promise.reject(errorMessage)); store.test(done, planningUi.duplicate(data.plannings[0])) .then(null, (error) => { expect(error).toEqual(errorMessage); @@ -547,12 +539,10 @@ describe('actions.planning.ui', () => { sinon.stub(planningUi, 'save').callsFake( (item, updates) => Promise.resolve({...item, ...updates}) ); - sinon.stub(locks, 'unlock').callsFake((item) => (Promise.resolve(item))); }); afterEach(() => { restoreSinonStub(planningUi.save); - restoreSinonStub(locks.unlock); }); it('assignToAgenda adds and agenda to planning item and calls save and unlocks item', (done) => { @@ -574,65 +564,14 @@ describe('actions.planning.ui', () => { expect(services.notify.success.args[0]).toEqual( ['Agenda assigned to the planning item.']); - expect(locks.unlock.callCount).toBe(1); - expect(locks.unlock.args[0]).toEqual([planningUtils.modifyForClient(planningWithAgenda)]); + expect(planningApi.locks.unlockItem.callCount).toBe(1); + expect(planningApi.locks.unlockItem.args[0]).toEqual([ + planningUtils.modifyForClient(planningWithAgenda), + ]); done(); }) .catch(done.fail); }); }); - - it('addCoverageToWorkflow', (done) => { - const modifiedPlanning = planningUtils.modifyForClient(data.plannings[0]); - const coverage = { - ...modifiedPlanning.coverages[0], - news_coverage_status: { - name: 'Coverage intended', - label: 'Planned', - qcode: 'ncostat:int', - }, - }; - - store.test(done, planningUi.addCoverageToWorkflow( - data.plannings[0], - { - ...coverage, - planning: { - ...coverage.planning, - internal_note: 'Please cover this', - g2_content_type: 'photo', - }, - }, - 0 - )) - .then(() => { - expect(planningApi.save.callCount).toBe(1); - expect(planningApi.save.args[0]).toEqual([ - data.plannings[0], - { - coverages: [ - { - ...coverage, - planning: { - ...coverage.planning, - internal_note: 'Please cover this', - g2_content_type: 'photo', - }, - workflow_status: 'active', - assigned_to: { - ...coverage.assigned_to, - state: 'assigned', - }, - }, - modifiedPlanning.coverages[1], - modifiedPlanning.coverages[2], - ], - }, - ]); - - done(); - }) - .catch(done.fail); - }); }); diff --git a/client/actions/planning/ui.ts b/client/actions/planning/ui.ts index 7b0d751c9..2c0088a88 100644 --- a/client/actions/planning/ui.ts +++ b/client/actions/planning/ui.ts @@ -1,7 +1,8 @@ import {IPlanningSearchParams} from '../../interfaces'; +import {planningApi} from '../../superdeskApi'; + import {showModal} from '../index'; -import planningApi from './api'; -import {locks} from '../index'; +import planningApis from './api'; import main from '../main'; import eventsUi from '../events/ui'; import {ITEM_TYPE} from '../../constants'; @@ -28,7 +29,7 @@ import {get, orderBy, cloneDeep} from 'lodash'; */ const spike = (item) => ( (dispatch, getState, {notify}) => ( - dispatch(planningApi.spike(item)) + dispatch(planningApis.spike(item)) .then((items) => { notify.success(gettext('The Planning Item(s) has been spiked.')); dispatch(main.closePreviewAndEditorForItems(items)); @@ -49,7 +50,7 @@ const spike = (item) => ( */ const unspike = (item) => ( (dispatch, getState, {notify}) => ( - dispatch(planningApi.unspike(item)) + dispatch(planningApis.unspike(item)) .then((items) => { dispatch(main.closePreviewAndEditorForItems(items)); notify.success(gettext('The Planning Item(s) has been unspiked.')); @@ -93,7 +94,7 @@ const addToList = (ids) => ({ function fetchToList(params: IPlanningSearchParams) { return (dispatch) => { dispatch(self.requestPlannings(params)); - return dispatch(planningApi.fetch(params)) + return dispatch(planningApis.fetch(params)) .then((items) => (dispatch(self.setInList( items.map((p) => p._id) )))); @@ -120,7 +121,7 @@ const loadMore = () => ( page: get(previousParams, 'page', 1) + 1, }; - return dispatch(planningApi.fetch(params)) + return dispatch(planningApis.fetch(params)) .then((items) => { if (get(items, 'length', 0) === MAIN.PAGE_SIZE) { dispatch(self.requestPlannings(params)); @@ -145,7 +146,7 @@ const refetch = () => ( dispatch(main.fetchItemHistory({_id: previewId, type: ITEM_TYPE.PLANNING})); } - return dispatch(planningApi.refetch()) + return dispatch(planningApis.refetch()) .then( (items) => { dispatch(self.setInList(items.map((p) => p._id))); @@ -182,7 +183,7 @@ const scheduleRefetch = () => ( */ const assignToAgenda = (item, agenda) => ( (dispatch, getState, {notify}) => ( - dispatch(locks.lock(item, 'assign_agenda')) + planningApi.locks.lockItem(item, 'assign_agenda') .then((original) => { const updates = cloneDeep(original); @@ -202,7 +203,7 @@ const assignToAgenda = (item, agenda) => ( const duplicate = (plan) => ( (dispatch, getState, {notify}) => ( - dispatch(planningApi.duplicate(plan)) + dispatch(planningApis.duplicate(plan)) .then((newPlan) => { notify.success(gettext('Planning duplicated')); const openInModal = selectors.forms.currentItemIdModal(getState()); @@ -228,7 +229,7 @@ const duplicate = (plan) => ( const cancelPlanning = (original, updates) => ( (dispatch, getState, {notify}) => ( - dispatch(planningApi.cancel(original, updates)) + dispatch(planningApis.cancel(original, updates)) .then((plan) => { notify.success(gettext('Planning Item has been cancelled')); dispatch(main.closePreviewAndEditorForItems([plan], null, '_id', true)); @@ -248,7 +249,7 @@ const cancelAllCoverage = (original, updates) => ( // delete _cancelAllCoverage used for UI purposes delete original._cancelAllCoverage; - return dispatch(planningApi.cancelAllCoverage(original, updates)) + return dispatch(planningApis.cancelAllCoverage(original, updates)) .then((plan) => { notify.success(gettext('All Coverage has been cancelled')); return Promise.resolve(plan); @@ -271,7 +272,7 @@ const openFeaturedPlanningModal = () => ( return dispatch(showModal({modalType: MODALS.UNLOCK_FEATURED_STORIES})); } - return dispatch(planningApi.lockFeaturedPlanning()) + return planningApi.locks.lockFeaturedPlanning() .then(() => dispatch(showModal({ modalType: MODALS.FEATURED_STORIES, })), @@ -292,7 +293,7 @@ const modifyPlanningFeatured = (item, remove = false) => ( dispatch(self._modifyPlanningFeatured(unlockedItem, remove)) .then((updatedItem) => { if (get(previousLock, 'action')) { - return dispatch(locks.lock(updatedItem, previousLock.action)); + return planningApi.locks.lockItem(updatedItem, previousLock.action); } }) ) @@ -307,7 +308,7 @@ const modifyPlanningFeatured = (item, remove = false) => ( */ const _modifyPlanningFeatured = (item, remove = false) => ( (dispatch, getState, {api, notify}) => ( - dispatch(locks.lock(item, remove ? 'remove_featured' : 'add_featured')) + planningApi.locks.lockItem(item, remove ? 'remove_featured' : 'add_featured') .then((lockedItem) => { lockedItem.featured = !remove; return dispatch(self.saveAndUnlockPlanning(lockedItem)).then((updatedItem) => { @@ -337,7 +338,7 @@ const openSpikeModal = (plan, post = false, modalProps = {}) => ( dispatch(self._openActionModal( plan, PLANNING.ITEM_ACTIONS.SPIKE.actionName, - null, + PLANNING.ITEM_ACTIONS.SPIKE.lock_action, post, false, modalProps @@ -350,7 +351,7 @@ const openUnspikeModal = (plan, post = false) => ( (dispatch) => dispatch(self._openActionModal( plan, PLANNING.ITEM_ACTIONS.UNSPIKE.actionName, - null, + PLANNING.ITEM_ACTIONS.UNSPIKE.lock_action, post )) ); @@ -373,7 +374,7 @@ const _openActionModal = ( modalProps = {} ) => ( (dispatch, getState, {notify}) => ( - dispatch(planningApi.lock(plan, lockAction)) + planningApi.locks.lockItem(plan, lockAction) .then((lockedPlanning) => { lockedPlanning._post = post; return dispatch(showModal({ @@ -409,9 +410,9 @@ const save = (original, updates) => ( null, {}, original, - planningApi.save.bind(null, original, updates))); + planningApis.save.bind(null, original, updates))); } - return dispatch(planningApi.save(original, updates)); + return dispatch(planningApis.save(original, updates)); } } ); @@ -435,19 +436,19 @@ const onAddCoverageClick = (item) => ( const currentItem = selectors.forms.currentItem(state); if (currentItem && getItemId(item) !== getItemId(currentItem)) { - dispatch(locks.unlock(currentItem)); + planningApi.locks.unlockItem(currentItem); } // If it is an existing item and the item is not locked // then lock the item, otherwise return the existing item if (isExistingItem(item) && !lockUtils.getLock(item, lockedItems)) { - promise = dispatch(locks.lock(item)); + promise = planningApi.locks.lockItem(item); } else { promise = Promise.resolve(item); } return promise.then((lockedItem) => { - dispatch(planningApi.receivePlannings([lockedItem])); + dispatch(planningApis.receivePlannings([lockedItem])); dispatch(main.closeEditor()); dispatch(main.openForEdit(lockedItem)); return Promise.resolve(lockedItem); @@ -466,7 +467,7 @@ const saveFromAuthoring = (original, updates) => ( dispatch(actions.actionInProgress(true)); let resolved = true; - return dispatch(planningApi.save(original, updates)) + return dispatch(planningApis.save(original, updates)) .then((newPlan) => { const newsItem = get(selectors.general.modalProps(getState()), 'newsItem') || get(selectors.general.previousModalProps(getState()), 'newsItem'); @@ -510,27 +511,6 @@ const saveFromAuthoring = (original, updates) => ( } ); -/** - * Action to update the values of a single Coverage so the Assignment is placed in the workflow - * @param {object} original - Original Planning item - * @param {object} updatedCoverage - Coverage to update (along with any coverage fields to update as well) - * @param {number} index - index of the Coverage in the coverages[] array - */ -const addCoverageToWorkflow = (original, updatedCoverage, index) => ( - (dispatch, getState, {notify}) => { - let updates = {coverages: cloneDeep(original.coverages)}; - - updates.coverages[index] = planningUtils.getActiveCoverage(updatedCoverage, - selectors.general.newsCoverageStatus(getState())); - - return dispatch(planningApi.save(original, updates)) - .then((savedItem) => { - notify.success(gettext('Coverage added to workflow.')); - return dispatch(self.updateItemOnSave(savedItem)); - }); - } -); - const addScheduledUpdateToWorkflow = (original, coverage, coverageIndex, scheduledUpdate, index) => ( (dispatch, getState, {notify}) => { let updates = {coverages: cloneDeep(original.coverages)}; @@ -539,7 +519,7 @@ const addScheduledUpdateToWorkflow = (original, coverage, coverageIndex, schedul coverage.scheduled_updates[index] = planningUtils.getActiveCoverage(scheduledUpdate, selectors.general.newsCoverageStatus(getState())); - return dispatch(planningApi.save(original, updates)) + return dispatch(planningApis.save(original, updates)) .then((savedItem) => { notify.success(gettext('Scheduled update added to workflow.')); return dispatch(self.updateItemOnSave(savedItem)); @@ -560,7 +540,7 @@ const removeAssignment = (original, updatedCoverage, index) => ( updates.coverages[index] = coverage; - return dispatch(planningApi.save(original, updates)) + return dispatch(planningApis.save(original, updates)) .then((savedItem) => { notify.success(gettext('Removed assignment from coverage.')); return dispatch(self.updateItemOnSave(savedItem)); @@ -572,7 +552,7 @@ const updateItemOnSave = (savedItem) => ( (dispatch) => { const modifiedItem = planningUtils.modifyForClient(savedItem); - dispatch(planningApi.receivePlannings([modifiedItem])); + dispatch(planningApis.receivePlannings([modifiedItem])); return Promise.resolve(modifiedItem); } ); @@ -612,7 +592,7 @@ const cancelCoverage = (original, updatedCoverage, index, scheduledUpdate, sched updates.coverages[index].scheduled_updates[scheduledUpdateIndex] = cloneDeep(scheduledUpdate); } - return dispatch(planningApi.save(original, updates)) + return dispatch(planningApis.save(original, updates)) .then((savedItem) => { notify.success(gettext('Coverage cancelled.')); return dispatch(self.updateItemOnSave(savedItem)); @@ -644,7 +624,6 @@ const self = { saveFromAuthoring, scheduleRefetch, assignToAgenda, - addCoverageToWorkflow, removeAssignment, _modifyPlanningFeatured, modifyPlanningFeatured, diff --git a/client/actions/tests/agenda_test.ts b/client/actions/tests/agenda_test.ts index 4c10fa37d..addd07b89 100644 --- a/client/actions/tests/agenda_test.ts +++ b/client/actions/tests/agenda_test.ts @@ -326,6 +326,16 @@ describe('agenda', () => { expect(apiSpy.save.args[0]).toEqual([ {}, { + type: 'planning', + state: 'draft', + item_class: 'plinat:newscoverage', + language: 'en', + languages: ['en'], + flags: { + marked_for_not_publication: false, + overide_auto_assign_to_workflow: false, + }, + coverages: [], event_item: events[0]._id, planning_date: events[0].dates.start, slugline: events[0].slugline, @@ -344,7 +354,6 @@ describe('agenda', () => { }], internal_note: 'internal note', ednote: 'Editorial note about this Event', - language: undefined, }, ]); diff --git a/client/actions/tests/locks_test.ts b/client/actions/tests/locks_test.ts deleted file mode 100644 index d1deab127..000000000 --- a/client/actions/tests/locks_test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import sinon from 'sinon'; - -import {WORKSPACE} from '../../constants'; -import {getTestActionStore, restoreSinonStub} from '../../utils/testUtils'; - -import locks from '../locks'; -import assignmentsApi from '../assignments/api'; - -import eventsApi from '../events/api'; -import planningApi from '../planning/api'; - -describe('actions.locks', () => { - let store; - let data; - let services; - - beforeEach(() => { - store = getTestActionStore(); - data = store.data; - services = store.services; - - sinon.stub(assignmentsApi, 'queryLockedAssignments').callsFake( - () => (Promise.resolve(['as']))); - - sinon.stub(eventsApi, 'lock').callsFake((item) => Promise.resolve(item)); - sinon.stub(planningApi, 'lock').callsFake((item) => Promise.resolve(item)); - }); - - afterEach(() => { - restoreSinonStub(assignmentsApi.queryLockedAssignments); - restoreSinonStub(eventsApi.lock); - restoreSinonStub(planningApi.lock); - }); - - describe('loadAssignmentLocks', () => { - it('queries locked assignments and dispatches RECEIVE_LOCKS', (done) => { - store.test(done, locks.loadAssignmentLocks()) - .then(() => { - expect(assignmentsApi.queryLockedAssignments.callCount).toBe(1); - expect(store.dispatch.args[1]).toEqual([{ - type: 'RECEIVE_LOCKS', - payload: {assignments: ['as']}, - }]); - done(); - }) - .catch(done.fail); - }); - }); - - describe('lock', () => { - it('determines the item type and calls the appropriate lock action', (done) => ( - store.test(done, locks.lock(data.events[0])) - .then(() => { - expect(eventsApi.lock.callCount).toBe(1); - expect(eventsApi.lock.args[0]).toEqual([data.events[0], 'edit']); - - return store.test(done, locks.lock(data.plannings[0])); - }) - .then(() => { - expect(planningApi.lock.callCount).toBe(1); - expect(planningApi.lock.args[0]).toEqual([data.plannings[0], 'edit']); - - done(); - }) - ).catch(done.fail)); - - it('Uses add_to_planning if in AUTHORING workspace', (done) => { - store.initialState.workspace.currentWorkspace = WORKSPACE.AUTHORING; - return store.test(done, locks.lock(data.plannings[0])) - .then(() => { - expect(planningApi.lock.callCount).toBe(1); - expect(planningApi.lock.args[0]).toEqual([data.plannings[0], 'add_to_planning']); - - done(); - }) - .catch(done.fail); - }); - - it('Returns Promise.reject if could not determine item type', (done) => ( - store.test(done, locks.lock({test: 'something'})) - .then(null, (error) => { - expect(error).toBe( - 'Failed to lock the item, could not determine item type!' - ); - expect(services.notify.error.callCount).toBe(1); - expect(services.notify.error.args[0]).toEqual( - ['Failed to lock the item, could not determine item type!'] - ); - - done(); - }) - ).catch(done.fail)); - }); -}); diff --git a/client/actions/tests/main_test.ts b/client/actions/tests/main_test.ts index d99a0329f..d8be274db 100644 --- a/client/actions/tests/main_test.ts +++ b/client/actions/tests/main_test.ts @@ -1,6 +1,7 @@ import sinon from 'sinon'; import moment from 'moment'; +import {planningApi} from '../../superdeskApi'; import {getTestActionStore, restoreSinonStub} from '../../utils/testUtils'; import {removeAutosaveFields, modifyForClient} from '../../utils'; import {main} from '../'; @@ -8,7 +9,7 @@ import {AGENDA, POST_STATE} from '../../constants'; import eventsUi from '../events/ui'; import eventsApi from '../events/api'; import planningUi from '../planning/ui'; -import planningApi from '../planning/api'; +import planningApis from '../planning/api'; import eventsPlanningUi from '../eventsPlanning/ui'; import {locks} from '../'; @@ -56,12 +57,12 @@ describe('actions.main', () => { describe('post', () => { beforeEach(() => { sinon.stub(eventsApi, 'post').returns(Promise.resolve(data.events[0])); - sinon.stub(planningApi, 'post').returns(Promise.resolve(data.plannings[0])); + sinon.stub(planningApis, 'post').returns(Promise.resolve(data.plannings[0])); }); afterEach(() => { restoreSinonStub(eventsApi.post); - restoreSinonStub(planningApi.post); + restoreSinonStub(planningApis.post); }); it('calls events.ui.post', (done) => ( @@ -81,8 +82,8 @@ describe('actions.main', () => { it('calls planning.ui.post', (done) => ( store.test(done, main.post(data.plannings[0])) .then(() => { - expect(planningApi.post.callCount).toBe(1); - expect(planningApi.post.args[0]).toEqual([ + expect(planningApis.post.callCount).toBe(1); + expect(planningApis.post.args[0]).toEqual([ data.plannings[0], {pubstatus: POST_STATE.USABLE}, ]); @@ -109,12 +110,12 @@ describe('actions.main', () => { describe('unpost', () => { beforeEach(() => { sinon.stub(eventsApi, 'unpost').returns(Promise.resolve(data.events[0])); - sinon.stub(planningApi, 'unpost').returns(Promise.resolve(data.plannings[0])); + sinon.stub(planningApis, 'unpost').returns(Promise.resolve(data.plannings[0])); }); afterEach(() => { restoreSinonStub(eventsApi.unpost); - restoreSinonStub(planningApi.unpost); + restoreSinonStub(planningApis.unpost); }); it('calls events.ui.unpost', (done) => ( @@ -134,8 +135,8 @@ describe('actions.main', () => { it('calls planning.ui.unpost', (done) => ( store.test(done, main.unpost(data.plannings[0])) .then(() => { - expect(planningApi.unpost.callCount).toBe(1); - expect(planningApi.unpost.args[0]).toEqual([ + expect(planningApis.unpost.callCount).toBe(1); + expect(planningApis.unpost.args[0]).toEqual([ data.plannings[0], {pubstatus: POST_STATE.CANCELLED}, ]); @@ -323,12 +324,12 @@ describe('actions.main', () => { describe('loadItem', () => { beforeEach(() => { sinon.stub(eventsApi, 'fetchById').returns(Promise.resolve(data.events[0])); - sinon.stub(planningApi, 'fetchById').returns(Promise.resolve(data.plannings[0])); + sinon.stub(planningApis, 'fetchById').returns(Promise.resolve(data.plannings[0])); }); afterEach(() => { restoreSinonStub(eventsApi.fetchById); - restoreSinonStub(planningApi.fetchById); + restoreSinonStub(planningApis.fetchById); }); it('loads an Event for preview', (done) => ( @@ -373,8 +374,8 @@ describe('actions.main', () => { expect(store.dispatch.callCount).toBe(4); expect(store.dispatch.args[0]).toEqual([{type: 'MAIN_PREVIEW_LOADING_START'}]); - expect(planningApi.fetchById.callCount).toBe(1); - expect(planningApi.fetchById.args[0]).toEqual(['p1', {force: false}]); + expect(planningApis.fetchById.callCount).toBe(1); + expect(planningApis.fetchById.args[0]).toEqual(['p1', {force: false}]); expect(store.dispatch.args[3]).toEqual([{type: 'MAIN_PREVIEW_LOADING_COMPLETE'}]); @@ -390,8 +391,8 @@ describe('actions.main', () => { expect(store.dispatch.callCount).toBe(4); expect(store.dispatch.args[0]).toEqual([{type: 'MAIN_EDIT_LOADING_START'}]); - expect(planningApi.fetchById.callCount).toBe(1); - expect(planningApi.fetchById.args[0]).toEqual(['p1', {force: false}]); + expect(planningApis.fetchById.callCount).toBe(1); + expect(planningApis.fetchById.args[0]).toEqual(['p1', {force: false}]); expect(store.dispatch.args[3]).toEqual([{type: 'MAIN_EDIT_LOADING_COMPLETE'}]); @@ -515,16 +516,14 @@ describe('actions.main', () => { beforeEach(() => { actionCallback = sinon.stub().returns(Promise.resolve()); - sinon.stub(locks, 'unlock').callsFake((item) => Promise.resolve(item)); - // sinon.stub(main, 'lockAndEdit').callsFake((item) => Promise.resolve(item)); + sinon.stub(planningApi.locks, 'unlockItem').callsFake((item) => Promise.resolve(item)); sinon.stub(main, 'openForEdit'); sinon.stub(main, 'saveAutosave').callsFake((item) => Promise.resolve(item)); sinon.stub(main, 'openIgnoreCancelSaveModal').callsFake((item) => Promise.resolve(item)); }); afterEach(() => { - restoreSinonStub(locks.unlock); - // restoreSinonStub(main.lockAndEdit); + restoreSinonStub(planningApi.locks.unlockItem); restoreSinonStub(main.openForEdit); restoreSinonStub(main.saveAutosave); restoreSinonStub(main.openIgnoreCancelSaveModal); @@ -554,8 +553,8 @@ describe('actions.main', () => { return store.test(done, main.openActionModalFromEditor(data.events[0], 'title', actionCallback)) .then(() => { - expect(locks.unlock.callCount).toBe(1); - expect(locks.unlock.args[0]).toEqual([data.events[0]]); + expect(planningApi.locks.unlockItem.callCount).toBe(1); + expect(planningApi.locks.unlockItem.args[0]).toEqual([data.events[0]]); expect(actionCallback.callCount).toBe(1); expect(actionCallback.args[0]).toEqual([ @@ -572,8 +571,8 @@ describe('actions.main', () => { return store.test(done, main.openActionModalFromEditor(data.events[0], 'title', actionCallback)); }) .then(() => { - expect(locks.unlock.callCount).toBe(2); - expect(locks.unlock.args[1]).toEqual([data.events[0]]); + expect(planningApi.locks.unlockItem.callCount).toBe(2); + expect(planningApi.locks.unlockItem.args[1]).toEqual([data.events[0]]); expect(actionCallback.callCount).toBe(2); expect(actionCallback.args[1]).toEqual([ @@ -868,7 +867,7 @@ describe('actions.main', () => { beforeEach(() => { sinon.stub(planningUi, 'save').callsFake((item) => (Promise.resolve(item))); sinon.stub(eventsUi, 'saveWithConfirmation').callsFake((item) => (Promise.resolve(item))); - sinon.stub(locks, 'unlock').callsFake((item) => (Promise.resolve(item))); + sinon.stub(planningApi.locks, 'unlockItem').callsFake((item) => Promise.resolve(item)); }); it('saves and unlocks planning item', (done) => @@ -883,8 +882,8 @@ describe('actions.main', () => { {...data.plannings[0], slugline: 'New Slugger'}, ]); - expect(locks.unlock.callCount).toBe(1); - expect(locks.unlock.args[0]).toEqual([modifyForClient(data.plannings[0])]); + expect(planningApi.locks.unlockItem.callCount).toBe(1); + expect(planningApi.locks.unlockItem.args[0]).toEqual([modifyForClient(data.plannings[0])]); done(); }) @@ -902,8 +901,8 @@ describe('actions.main', () => { false, ]); - expect(locks.unlock.callCount).toBe(1); - expect(locks.unlock.args[0]).toEqual([modifyForClient(data.events[0])]); + expect(planningApi.locks.unlockItem.callCount).toBe(1); + expect(planningApi.locks.unlockItem.args[0]).toEqual([modifyForClient(data.events[0])]); done(); }) @@ -912,7 +911,7 @@ describe('actions.main', () => { afterEach(() => { restoreSinonStub(planningUi.save); - restoreSinonStub(locks.unlock); + restoreSinonStub(planningApi.locks.unlockItem); restoreSinonStub(eventsUi.saveWithConfirmation); }); }); diff --git a/client/api/assignments.ts b/client/api/assignments.ts new file mode 100644 index 000000000..ef355c8d5 --- /dev/null +++ b/client/api/assignments.ts @@ -0,0 +1,10 @@ +import {superdeskApi} from '../superdeskApi'; +import {IPlanningAPI, IAssignmentItem} from '../interfaces'; + +function getAssignmentById(assignmentId: IAssignmentItem['_id']): Promise { + return superdeskApi.dataApi.findOne('assignments', assignmentId); +} + +export const assignments: IPlanningAPI['assignments'] = { + getById: getAssignmentById, +}; diff --git a/client/api/autosave.ts b/client/api/autosave.ts index 7376a6363..8a8f6ac2c 100644 --- a/client/api/autosave.ts +++ b/client/api/autosave.ts @@ -26,8 +26,18 @@ function deleteAutosaveItem(item: IEventOrPlanningItem): Promise { ); } +function deleteAutosaveItemById( + itemType: IEventOrPlanningItem['type'], + itemId: IEventOrPlanningItem['_id'] +): Promise { + return planningApi.redux.store.dispatch( + actions.autosave.removeById(itemType, itemId) + ); +} + export const autosave: IPlanningAPI['autosave'] = { getById: getAutosaveItemById, save: saveAutosaveItem, delete: deleteAutosaveItem, + deleteById: deleteAutosaveItemById, }; diff --git a/client/api/combined.ts b/client/api/combined.ts index e4f91a2c8..cca8ece5f 100644 --- a/client/api/combined.ts +++ b/client/api/combined.ts @@ -26,6 +26,7 @@ function convertCombinedParams(params: ISearchParams): Partial include_associated_planning: params.include_associated_planning, source: cvsToString(params.source, 'id'), coverage_user_id: params.coverage_user_id, + priority: arrayToString(params.priority), }; } diff --git a/client/api/contentProfiles.ts b/client/api/contentProfiles.ts index 3ad59d078..6136ce23c 100644 --- a/client/api/contentProfiles.ts +++ b/client/api/contentProfiles.ts @@ -4,6 +4,7 @@ import { IPlanningContentProfile, IPlanningAPI, IEventOrPlanningItem, + IPlanningCoverageItem, IProfileMultilingualDetails, IProfileSchemaTypeString, } from '../interfaces'; @@ -30,11 +31,45 @@ function getAll(): Promise> { ) .then((response) => { response._items.forEach(sortProfileGroups); + enablePriorityInSearchProfile(response._items); return response._items; }); } +function enablePriorityInSearchProfile(profiles: Array) { + // Hack to enable/disable priority field in search profiles based on the content profiles + // TODO: Remove this hack when we implement a solution for all searchable fields + const profilesById: {[id: string]: IPlanningContentProfile} = profiles.reduce((profileMap, profile) => { + profileMap[profile.name] = profile; + + return profileMap; + }, {}); + const searchProfile = profilesById.advanced_search.editor; + const priorityEnabled = { + event: profilesById.event.editor.priority?.enabled === true, + planning: profilesById.planning.editor.priority?.enabled === true, + }; + + const priorityField = { + enabled: true, + index: 5, + group: 'common', + search_enabled: true, + filter_enabled: true, + }; + + if (priorityEnabled.event) { + searchProfile.event.priority = priorityField; + if (priorityEnabled.planning) { + searchProfile.combined.priority = priorityField; + } + } + if (priorityEnabled.planning) { + searchProfile.planning.priority = priorityField; + } +} + function getProfile(contentType: string): IPlanningContentProfile { const {getState} = planningApi.redux.store; @@ -192,9 +227,23 @@ function updateProfilesInStore(): Promise { }); } +function getDefaultValues(profile: IPlanningContentProfile): DeepPartial { + return Object.keys(profile?.schema ?? {}).reduce( + (defaults, field) => { + if (profile.schema[field]?.default_value != null) { + defaults[field] = profile.schema[field].default_value; + } + + return defaults; + }, + {} + ); +} + export const contentProfiles: IPlanningAPI['contentProfiles'] = { getAll: getAll, get: getProfile, + getDefaultValues: getDefaultValues, patch: patch, showManagePlanningProfileModal: showManagePlanningProfileModal, showManageEventProfileModal: showManageEventProfileModal, diff --git a/client/api/editor/item_events.ts b/client/api/editor/item_events.ts index 1527b1e53..7650b735c 100644 --- a/client/api/editor/item_events.ts +++ b/client/api/editor/item_events.ts @@ -9,6 +9,7 @@ import { IEditorBookmark, IEditorFormGroup, IEventItem, + IPlanningCoverageItem, IPlanningItem, IProfileSchemaTypeList, } from '../../interfaces'; @@ -20,6 +21,7 @@ import {TEMP_ID_PREFIX} from '../../constants'; import {AddPlanningBookmark, AssociatedPlanningsBookmark} from '../../components/Editor/bookmarks'; import {RelatedPlanningItem} from '../../components/fields/editor/EventRelatedPlannings/RelatedPlanningItem'; +import {convertEventToPlanningItem} from '../../actions'; export function getEventsInstance(type: EDITOR_TYPE): IEditorAPI['item']['events'] { @@ -79,22 +81,12 @@ export function getEventsInstance(type: EDITOR_TYPE): IEditorAPI['item']['events const plans = cloneDeep(event.associated_plannings || []); const id = generateTempId(); - plans.push({ + const newPlanningItem: Partial = { _id: id, - type: 'planning', - event_item: event._id, - slugline: event.slugline, - planning_date: event._sortDate || event.dates?.start, - internal_note: event.internal_note, - name: event.name, - place: event.place, - subject: event.subject, - anpa_category: event.anpa_category, - description_text: event.definition_short, - ednote: event.ednote, - agendas: [], - language: event.language, - }); + ...convertEventToPlanningItem(event as IEventItem), + }; + + plans.push(newPlanningItem); editor.form.changeField('associated_plannings', plans) .then(() => { @@ -159,6 +151,12 @@ export function getEventsInstance(type: EDITOR_TYPE): IEditorAPI['item']['events }); } + function addCoverageToWorkflow(original: IPlanningItem, coverage: IPlanningCoverageItem, index: number): void { + planningApi.planning.coverages.addCoverageToWorkflow(original, coverage, index).then((updatedPlan) => { + updatePlanningItem(original, updatedPlan, false); + }); + } + function onEventDatesChanged(updates: Partial) { const editor = planningApi.editor(type); const original = editor.form.getDiff(); @@ -200,5 +198,6 @@ export function getEventsInstance(type: EDITOR_TYPE): IEditorAPI['item']['events removePlanningItem, updatePlanningItem, onEventDatesChanged, + addCoverageToWorkflow, }; } diff --git a/client/api/events.ts b/client/api/events.ts index 7271de3b2..266d4cd0b 100644 --- a/client/api/events.ts +++ b/client/api/events.ts @@ -11,7 +11,7 @@ import {IRestApiResponse} from 'superdesk-api'; import {planningApi, superdeskApi} from '../superdeskApi'; import {EVENTS, TEMP_ID_PREFIX} from '../constants'; -import {convertCommonParams, cvsToString, searchRaw, searchRawGetAll} from './search'; +import {arrayToString, convertCommonParams, cvsToString, searchRaw, searchRawGetAll} from './search'; import {eventUtils, planningUtils} from '../utils'; import {eventProfile, eventSearchProfile} from '../selectors/forms'; import * as actions from '../actions'; @@ -23,6 +23,7 @@ function convertEventParams(params: ISearchParams): Partial { location: params.location?.qcode, calendars: cvsToString(params.calendars), no_calendar_assigned: params.no_calendar_assigned, + priority: arrayToString(params.priority), }; } @@ -109,15 +110,6 @@ export function getEventByIds( .then((response) => response._items); } -export function getLockedEvents(): Promise> { - return searchEventsGetAll({ - lock_state: LOCK_STATE.LOCKED, - directly_locked: true, - only_future: false, - include_killed: true, - }); -} - function getEventEditorProfile() { return eventProfile(planningApi.redux.store.getState()); } @@ -190,7 +182,6 @@ export const events: IPlanningAPI['events'] = { searchGetAll: searchEventsGetAll, getById: getEventById, getByIds: getEventByIds, - getLocked: getLockedEvents, getEditorProfile: getEventEditorProfile, getSearchProfile: getEventSearchProfile, create: create, diff --git a/client/api/featured.ts b/client/api/featured.ts index a519405db..1b19916c1 100644 --- a/client/api/featured.ts +++ b/client/api/featured.ts @@ -1,18 +1,10 @@ import moment from 'moment'; -import {IPlanningAPI, IFeaturedPlanningItem, IFeaturedPlanningLock} from '../interfaces'; -import {superdeskApi} from '../superdeskApi'; +import {IPlanningAPI, IFeaturedPlanningItem, IFeaturedPlanningSaveItem} from '../interfaces'; +import {planningApi, superdeskApi} from '../superdeskApi'; import {getIdForFeauturedPlanning} from '../utils'; - -function lockFeaturedPlanning(): Promise> { - return superdeskApi.dataApi.create>('planning_featured_lock', {}); -} - -function unlockFeaturedPlanning(): Promise { - return superdeskApi.dataApi.create('planning_featured_unlock', {}) - .then(() => undefined); -} +import {featuredPlanningItem} from '../selectors/featuredPlanning'; function fetchFeaturedPlanningItemById(id: string): Promise { return superdeskApi.dataApi.findOne('planning_featured', id); @@ -24,9 +16,17 @@ function fetchFeaturedPlanningItemByDate(date: moment.Moment): Promise): Promise { + const {getState} = planningApi.redux.store; + const original = featuredPlanningItem(getState()); + + return original == null ? + superdeskApi.dataApi.create('planning_featured', {...updates}) : + superdeskApi.dataApi.patch('planning_featured', original, {...updates}); +} + export const featured: IPlanningAPI['planning']['featured'] = { - lock: lockFeaturedPlanning, - unlock: unlockFeaturedPlanning, getById: fetchFeaturedPlanningItemById, getByDate: fetchFeaturedPlanningItemByDate, + save: saveFeaturedPlanning, }; diff --git a/client/api/index.ts b/client/api/index.ts index 2fcf451d2..91d82644b 100644 --- a/client/api/index.ts +++ b/client/api/index.ts @@ -9,10 +9,14 @@ import {locations} from './locations'; import {autosave} from './autosave'; import {editor} from './editor'; import {contentProfiles} from './contentProfiles'; +import {locks} from './locks'; +import {assignments} from './assignments'; +import {vocabularies} from './vocabularies'; export const planningApis: Omit = { events, planning, + assignments, combined, coverages, search, @@ -21,4 +25,6 @@ export const planningApis: Omit = { autosave, editor, contentProfiles, + locks, + vocabularies, }; diff --git a/client/api/locks.ts b/client/api/locks.ts new file mode 100644 index 000000000..fcc56baa6 --- /dev/null +++ b/client/api/locks.ts @@ -0,0 +1,319 @@ +import { + ILock, + ILockedItems, + IPlanningAPI, + IWebsocketMessageData, + IAssignmentOrPlanningItem, + IFeaturedPlanningLock, +} from '../interfaces'; +import {planningApi, superdeskApi} from '../superdeskApi'; + +import {EVENTS, LOCKS, PLANNING, WORKSPACE, ASSIGNMENTS} from '../constants'; + +import featuredPlanning from '../actions/planning/featuredPlanning'; +import {lockUtils, getErrorMessage, eventUtils, planningUtils, isExistingItem} from '../utils'; +import {currentWorkspace as getCurrentWorkspace} from '../selectors/general'; +import {getLockedItems} from '../selectors/locks'; + +function loadLockedItems(types?: Array<'events_and_planning' | 'featured_planning' | 'assignments'>): Promise { + const {gettext} = superdeskApi.localization; + const {notify} = superdeskApi.ui; + let url = 'planning_locks'; + + if ((types?.length ?? 0) > 0) { + url += `?repos=${types.join(',')}`; + } + + return superdeskApi.dataApi.queryRawJson(url).then( + (locks) => { + const {dispatch} = planningApi.redux.store; + + dispatch({ + type: LOCKS.ACTIONS.RECEIVE, + payload: locks, + }); + + // If `featured_planning` lock was retrieved, then update it's lock now + if (types == null || types.includes('featured_planning')) { + if (locks.featured?.user != null) { + dispatch(featuredPlanning.setLockUser(locks.featured.user, locks.featured.session)); + } else { + dispatch(featuredPlanning.setUnlocked()); + } + } + + // Make sure that all items that are locked are loaded into the store + return planningApi.combined.searchGetAll({ + item_ids: lockUtils.getLockedItemIds(locks), + only_future: false, + include_killed: true, + spike_state: 'draft', + exclude_rescheduled_and_cancelled: false, + include_associated_planning: true, + }).then( + (items) => { + dispatch({ + type: EVENTS.ACTIONS.ADD_EVENTS, + payload: items.filter((item) => item.type === 'event'), + }); + dispatch({ + type: PLANNING.ACTIONS.RECEIVE_PLANNINGS, + payload: items.filter((item) => item.type === 'planning'), + }); + }, + (error) => { + notify.error(getErrorMessage(error, gettext('Failed to load locked items'))); + + return Promise.reject(error); + } + ); + }, + (error) => { + notify.error(getErrorMessage(error, gettext('Failed to load item locks'))); + + return Promise.reject(error); + } + ); +} + +function setItemAsLocked(data: IWebsocketMessageData['ITEM_LOCKED']): void { + const {dispatch} = planningApi.redux.store; + + dispatch({ + type: LOCKS.ACTIONS.SET_ITEM_AS_LOCKED, + payload: data, + }); +} + +function setItemAsUnlocked(data: IWebsocketMessageData['ITEM_UNLOCKED']): void { + const {dispatch} = planningApi.redux.store; + + dispatch({ + type: LOCKS.ACTIONS.SET_ITEM_AS_UNLOCKED, + payload: data, + }); +} + +function getLockResourceName(itemType: IAssignmentOrPlanningItem['type']) { + switch (itemType) { + case 'event': + return 'events'; + case 'planning': + return 'planning'; + case 'assignment': + return 'assignments'; + } +} + +function lockItem(item: T, action?: string): Promise { + const {dispatch, getState} = planningApi.redux.store; + const resource = getLockResourceName(item.type); + const endpoint = `${resource}/${item._id}/lock`; + let lockAction = action; + + if (lockAction == null) { + const currentWorkspace = getCurrentWorkspace(getState()); + + lockAction = currentWorkspace === WORKSPACE.AUTHORING ? + PLANNING.ITEM_ACTIONS.ADD_TO_PLANNING.lock_action : + 'edit'; + } + + // @ts-ignore + return superdeskApi.dataApi.create(endpoint, {lock_action: lockAction}) + .then((lockedItem) => { + // On lock, file object in the item is lost, so replace it from original item + if (lockedItem.type !== 'assignment' && item.type !== 'assignment') { + lockedItem.files = item.files; + } if (lockedItem.type === 'event') { + eventUtils.modifyForClient(lockedItem); + } else if (lockedItem.type === 'planning') { + planningUtils.modifyForClient(lockedItem); + } + + locks.setItemAsLocked({ + item: lockedItem._id, + type: lockedItem.type, + event_item: lockedItem.type === 'planning' ? lockedItem.event_item : undefined, + recurrence_id: lockedItem.type !== 'assignment' ? lockedItem.recurrence_id : undefined, + etag: lockedItem._etag, + user: lockedItem.lock_user, + lock_session: lockedItem.lock_session, + lock_action: lockedItem.lock_action, + lock_time: lockedItem.lock_time, + }); + + if (lockedItem.type === 'event') { + dispatch({ + type: EVENTS.ACTIONS.LOCK_EVENT, + payload: {event: lockedItem}, + }); + } else if (lockedItem.type === 'planning') { + dispatch({ + type: PLANNING.ACTIONS.LOCK_PLANNING, + payload: {plan: lockedItem}, + }); + } else if (lockedItem.type === 'assignment') { + dispatch({ + type: ASSIGNMENTS.ACTIONS.LOCK_ASSIGNMENT, + payload: {assignment: lockedItem}, + }); + } + + return lockedItem; + }, (error) => { + const {gettext} = superdeskApi.localization; + const {notify} = superdeskApi.ui; + + notify.error(getErrorMessage(error, gettext('Failed to lock item'))); + + return Promise.reject(error); + }); +} + +function getItemById( + itemId: T['_id'], + itemType: T['type'] +): Promise { + // TODO: Figure out why this fails ts checks + switch (itemType) { + case 'event': + return planningApi.events.getById(itemId); + case 'planning': + return planningApi.planning.getById(itemId); + case 'assignment': + return planningApi.assignments.getById(itemId); + } +} + +function lockItemById( + itemId: T['_id'], + itemType: T['type'], + action: string +): Promise { + return getItemById(itemId, itemType).then((item) => locks.lockItem(item, action)); +} + +function unlockItem(item: T, reloadLocksIfNotFound: boolean = true): Promise { + if (!isExistingItem(item)) { + const autosaveDeletePromise = item.type === 'assignment' ? + Promise.resolve() : + planningApi.autosave.deleteById(item.type, item._id); + + if (item.type === 'event' && item._planning_item != null) { + return Promise.all([ + autosaveDeletePromise, + unlockItemById(item._planning_item, 'planning'), + ]).then((promiseResponses) => ( + promiseResponses[1] + )); + } + + return autosaveDeletePromise.then(() => item); + } + + const {dispatch, getState} = planningApi.redux.store; + const lockedItems = getLockedItems(getState()); + const currentLock = lockUtils.getLock(item, lockedItems); + + if (currentLock == null) { + if (reloadLocksIfNotFound) { + // The lock was not found in the local store + // Reload the list of locks now, and attempt to unlock again + return loadLockedItems().then(() => unlockItem(item, false)); + } else { + // The lock was still not found for this item + // It's possible that it is not actually locked + return Promise.resolve(item); + } + } + + const lockedItemId = currentLock.item_id; + const resource = getLockResourceName(currentLock.item_type); + const endpoint = `${resource}/${lockedItemId}/unlock`; + + return superdeskApi.dataApi.create(endpoint, {}) + .then((unlockedItem) => { + if (unlockedItem.type === 'event') { + eventUtils.modifyForClient(unlockedItem); + } else if (unlockedItem.type === 'planning') { + planningUtils.modifyForClient(unlockedItem); + } + + locks.setItemAsUnlocked({ + item: unlockedItem._id, + type: unlockedItem.type, + event_item: unlockedItem.type === 'planning' ? unlockedItem.event_item : undefined, + recurrence_id: unlockedItem.type !== 'assignment' ? unlockedItem.recurrence_id : undefined, + etag: unlockedItem._etag, + from_ingest: false, + user: unlockedItem.lock_user, + lock_session: unlockedItem.lock_session, + }); + + if (unlockedItem.type === 'event') { + dispatch({ + type: EVENTS.ACTIONS.UNLOCK_EVENT, + payload: {event: unlockedItem}, + }); + } else if (unlockedItem.type === 'planning') { + dispatch({ + type: PLANNING.ACTIONS.UNLOCK_PLANNING, + payload: {plan: unlockedItem}, + }); + } else if (unlockedItem.type === 'assignment') { + dispatch({ + type: ASSIGNMENTS.ACTIONS.UNLOCK_ASSIGNMENT, + payload: {assignment: unlockedItem}, + }); + } + + return unlockedItem; + }, (error) => { + const {gettext} = superdeskApi.localization; + const {notify} = superdeskApi.ui; + + notify.error(getErrorMessage(error, gettext('Failed to unlock item'))); + + return Promise.reject(error); + }); +} + +function unlockItemById(itemId: T['_id'], itemType: T['type']): Promise { + return getItemById(itemId, itemType).then((item) => unlockItem(item)); +} + +function unlockThenLockItem(item: T, action: string): Promise { + return unlockItem(item).then(() => (lockItem(item, action))); +} + +function lockFeaturedPlanning(): Promise { + return superdeskApi.dataApi.create('planning_featured_lock', {}) + .then((lockDetails) => { + const {dispatch} = planningApi.redux.store; + + dispatch(featuredPlanning.setLockUser(lockDetails.lock_user, lockDetails.lock_session)); + }); +} + +function unlockFeaturedPlanning(): Promise { + return superdeskApi.dataApi.create('planning_featured_unlock', {}) + .then(() => { + const {dispatch} = planningApi.redux.store; + + dispatch(featuredPlanning.setUnlocked()); + }); +} + +export const locks: IPlanningAPI['locks'] = { + loadLockedItems: loadLockedItems, + setItemAsLocked: setItemAsLocked, + setItemAsUnlocked: setItemAsUnlocked, + lockItem: lockItem, + lockItemById: lockItemById, + unlockItem: unlockItem, + unlockItemById: unlockItemById, + unlockThenLockItem: unlockThenLockItem, + lockFeaturedPlanning: lockFeaturedPlanning, + unlockFeaturedPlanning: unlockFeaturedPlanning, +}; diff --git a/client/api/planning.ts b/client/api/planning.ts index 1996683e9..845539c55 100644 --- a/client/api/planning.ts +++ b/client/api/planning.ts @@ -1,20 +1,22 @@ +import {cloneDeep} from 'lodash'; + import { FILTER_TYPE, IEventItem, - IFeaturedPlanningLock, IG2ContentType, + IG2ContentType, IPlanningAPI, + IPlanningCoverageItem, IPlanningItem, ISearchAPIParams, ISearchParams, ISearchSpikeState, - LOCK_STATE, } from '../interfaces'; import {arrayToString, convertCommonParams, searchRaw, searchRawGetAll, cvsToString} from './search'; import {planningApi, superdeskApi} from '../superdeskApi'; import {IRestApiResponse} from 'superdesk-api'; -import {planningUtils} from '../utils'; +import {planningUtils, getErrorMessage} from '../utils'; import {planningProfile, planningSearchProfile} from '../selectors/forms'; import {featured} from './featured'; -import {PLANNING, POST_STATE} from '../constants'; +import {PLANNING} from '../constants'; import * as selectors from '../selectors'; import * as actions from '../actions'; @@ -32,6 +34,8 @@ function convertPlanningParams(params: ISearchParams): Partial g2_content_type: params.g2_content_type?.qcode, source: cvsToString(params.source, 'id'), coverage_user_id: params.coverage_user_id, + coverage_assignment_status: params.coverage_assignment_status, + priority: arrayToString(params.priority), }; } @@ -128,35 +132,6 @@ export function getPlanningByIds( .then((response) => response._items); } -export function getLockedPlanningItems(): Promise> { - return searchPlanningGetAll({ - lock_state: LOCK_STATE.LOCKED, - directly_locked: true, - only_future: false, - include_killed: true, - }); -} - -export function getLockedFeaturedPlanning(): Promise> { - return superdeskApi.dataApi.queryRawJson>( - 'planning_featured_lock', - { - source: JSON.stringify({ - query: { - constant_score: { - filter: { - exists: { - field: 'lock_session', - }, - }, - }, - }, - }) - } - ) - .then((response) => response._items); -} - function getPlanningEditorProfile() { return planningProfile(planningApi.redux.store.getState()); } @@ -220,13 +195,42 @@ function setDefaultValues( ); } +function addCoverageToWorkflow( + plan: IPlanningItem, + coverage: IPlanningCoverageItem, + index: number +): Promise { + const {getState, dispatch} = planningApi.redux.store; + const {gettext} = superdeskApi.localization; + const {notify} = superdeskApi.ui; + + const coverageStatuses = selectors.general.newsCoverageStatus(getState()); + const updates = {coverages: cloneDeep(plan.coverages)}; + + updates.coverages[index] = planningUtils.getActiveCoverage(coverage, coverageStatuses); + + return planning.update(plan, updates) + .then((updatedPlan) => { + notify.success(gettext('Coverage added to workflow.')); + dispatch(actions.planning.api.receivePlannings([updatedPlan])); + + return updatedPlan; + }) + .catch((error) => { + notify.error(getErrorMessage( + error, + gettext('Failed to add coverage to workflow') + )); + + return Promise.reject(error); + }); +} + export const planning: IPlanningAPI['planning'] = { search: searchPlanning, searchGetAll: searchPlanningGetAll, getById: getPlanningById, getByIds: getPlanningByIds, - getLocked: getLockedPlanningItems, - getLockedFeatured: getLockedFeaturedPlanning, getEditorProfile: getPlanningEditorProfile, getSearchProfile: getPlanningSearchProfile, featured: featured, @@ -235,5 +239,6 @@ export const planning: IPlanningAPI['planning'] = { createFromEvent: createFromEvent, coverages: { setDefaultValues: setDefaultValues, + addCoverageToWorkflow: addCoverageToWorkflow, }, }; diff --git a/client/api/search.ts b/client/api/search.ts index 50bcbaa98..66cd2b1e3 100644 --- a/client/api/search.ts +++ b/client/api/search.ts @@ -2,6 +2,7 @@ import {ISearchAPIParams, ISearchParams} from '../interfaces'; import {superdeskApi} from '../superdeskApi'; import {IRestApiResponse} from 'superdesk-api'; import {getDateTimeElasticFormat, getTimeZoneOffset} from '../utils'; +import {default as timeUtils} from '../utils/time'; export function cvsToString(items?: Array<{[key: string]: any}>, field: string = 'qcode'): string { @@ -11,7 +12,7 @@ export function cvsToString(items?: Array<{[key: string]: any}>, field: string = ); } -export function arrayToString(items?: Array): string { +export function arrayToString(items?: Array): string { return (items ?? []) .join(','); } @@ -48,6 +49,7 @@ export function convertCommonParams(params: ISearchParams): Partial { + let redux: Store; + + beforeEach(() => { + redux = createTestStore(); + planningApi.redux.store = redux; + sinon.stub(planningApi.redux.store, 'dispatch').callThrough(); + }); + afterEach(() => { + restoreSinonStub(planningApi.redux.store.dispatch); + }); + + it('store locks are managed through setItemAsLocked and setItemAsUnlocked functions', () => { + const itemLock = { + item: testData.events[0]._id, + type: testData.events[0].type, + event_item: undefined, + etag: testData.events[0]._etag, + user: testData.lockedEvents[0].lock_user, + lock_session: testData.lockedEvents[0].lock_session, + }; + + expect(selectors.locks.getLockedItems(redux.getState())).toEqual({ + event: {}, + planning: {}, + assignment: {}, + recurring: {}, + }); + planningApi.locks.setItemAsLocked({ + ...itemLock, + lock_action: testData.lockedEvents[0].lock_action, + lock_time: testData.lockedEvents[0].lock_time, + recurrence_id: undefined, + }); + expect(selectors.locks.getLockedItems(redux.getState())).toEqual({ + event: {[testData.events[0]._id]: testData.eventLocks.e1}, + planning: {}, + assignment: {}, + recurring: {}, + }); + planningApi.locks.setItemAsUnlocked({ + ...itemLock, + from_ingest: false, + }); + expect(selectors.locks.getLockedItems(redux.getState())).toEqual({ + event: {}, + planning: {}, + assignment: {}, + recurring: {}, + }); + }); + + it('lockItemById attempts to load the item by id before locking it', (done) => { + superdeskApi.dataApi.findOne = sinon.stub().callsFake(() => Promise.resolve(testData.events[0])); + sinon.stub(planningApi.locks, 'lockItem').callsFake((e) => e); + + planningApi.locks.lockItemById(testData.events[0]._id, testData.events[0].type, 'cancel').then((lockedItem) => { + expect(lockedItem).toEqual(testData.events[0]); + expect(planningApi.locks.lockItem.callCount).toBe(1); + expect(planningApi.locks.lockItem.args[0]).toEqual([testData.events[0], 'cancel']); + done(); + }) + .finally(() => { + restoreSinonStub(planningApi.locks.lockItem); + }); + }); + + describe('locking items', () => { + let mock_api_lock_unlock_data; + let state: IPlanningAppState; + + beforeEach(() => { + superdeskApi.dataApi.create = sinon.stub().callsFake((resource: string, updates) => { + if (resource.endsWith('/lock')) { + return Promise.resolve({ + ...mock_api_lock_unlock_data, + ...updates, + }); + } else if (resource.endsWith('/unlock')) { + return Promise.resolve({ + ...mock_api_lock_unlock_data, + ...updates, + lock_action: null, + lock_user: null, + lock_session: null, + lock_time: null, + }); + } + + return updates; + }); + + superdeskApi.dataApi.queryRawJson = sinon.stub().callsFake((resource: string) => { + if (resource.startsWith('events_planning_search')) { + return Promise.resolve({ + _items: [testData.lockedEvents[0]], + _links: {}, + _meta: {total: 1}, + }); + } else if (resource.startsWith('planning_locks')) { + const lockedEvent = testData.lockedEvents[0]; + + return Promise.resolve({ + assignment: {}, + event: { + [lockedEvent._id]: { + item_id: lockedEvent._id, + item_type: lockedEvent.type, + user: lockedEvent.lock_user, + session: lockedEvent.lock_session, + action: lockedEvent.lock_action, + time: lockedEvent.lock_time, + }, + }, + planning: {}, + recurring: {}, + }); + } + + return Promise.resolve({ + _items: [], + _links: {}, + _meta: {total: 0}, + }); + }); + }); + + it('can lock/unlock an Event', (done) => { + mock_api_lock_unlock_data = testData.lockedEvents[0]; + state = redux.getState(); + + expect(selectors.locks.getLockedItems(state).event.e1).toBeUndefined(); + + planningApi.locks.lockItem(testData.events[0], 'edit') + .then((lockedItem) => { + // `dataApi` was called with the correct URL and params + expect(superdeskApi.dataApi.create.callCount).toBe(1); + expect(superdeskApi.dataApi.create.args[0]).toEqual([ + 'events/e1/lock', + {lock_action: 'edit'}, + ]); + + // Lock is added to the store + state = redux.getState(); + expect(selectors.locks.getLockedItems(state).event.e1).toEqual(testData.eventLocks.e1); + + // Item specific dispatches + expect(redux.dispatch.callCount).toBe(2); + expect(redux.dispatch.args[1]).toEqual([{ + type: EVENTS.ACTIONS.LOCK_EVENT, + payload: {event: lockedItem}, + }]); + + return planningApi.locks.unlockItem(testData.events[0]); + }) + .then(() => { + // Lock is removed from the store + state = redux.getState(); + expect(selectors.locks.getLockedItems(state).event.e1).toBeUndefined(); + + // Item specific dispatches + expect(redux.dispatch.callCount).toBe(4); + expect(redux.dispatch.args[3]).toEqual([{ + type: EVENTS.ACTIONS.UNLOCK_EVENT, + payload: { + event: jasmine.objectContaining({ + _id: testData.lockedEvents[0]._id, + lock_action: null, + lock_user: null, + lock_session: null, + lock_time: null, + }), + }, + }]); + + done(); + }); + }); + + it('can lock/unlock a Planning item', (done) => { + mock_api_lock_unlock_data = testData.lockedPlannings[0]; + state = redux.getState(); + + expect(selectors.locks.getLockedItems(state).planning.p1).toBeUndefined(); + + planningApi.locks.lockItem(testData.plannings[0], 'cancel') + .then((lockedItem) => { + // `dataApi` was called with the correct URL and params + expect(superdeskApi.dataApi.create.callCount).toBe(1); + expect(superdeskApi.dataApi.create.args[0]).toEqual([ + 'planning/p1/lock', + {lock_action: 'cancel'}, + ]); + + // Lock is added to the store + state = redux.getState(); + expect(selectors.locks.getLockedItems(state).planning.p1).toEqual({ + ...testData.planningLocks.p1, + action: 'cancel', + }); + + // Item specific dispatches + expect(redux.dispatch.callCount).toBe(2); + expect(redux.dispatch.args[1]).toEqual([{ + type: PLANNING.ACTIONS.LOCK_PLANNING, + payload: {plan: lockedItem}, + }]); + + return planningApi.locks.unlockItem(testData.plannings[0]); + }) + .then(() => { + // Lock is removed from the store + state = redux.getState(); + expect(selectors.locks.getLockedItems(state).planning.p1).toBeUndefined(); + + // Item specific dispatches + expect(redux.dispatch.callCount).toBe(4); + expect(redux.dispatch.args[3]).toEqual([{ + type: PLANNING.ACTIONS.UNLOCK_PLANNING, + payload: { + plan: jasmine.objectContaining({ + _id: testData.lockedPlannings[0]._id, + lock_action: null, + lock_user: null, + lock_session: null, + lock_time: null, + }), + }, + }]); + + done(); + }); + }); + + it('can lock/unlock an Assignment', (done) => { + mock_api_lock_unlock_data = testData.lockedAssignments[0]; + state = redux.getState(); + + expect(selectors.locks.getLockedItems(state).assignment.as1).toBeUndefined(); + + planningApi.locks.lockItem(testData.assignments[0], 'reassign') + .then((lockedItem) => { + // `dataApi` was called with the correct URL and params + expect(superdeskApi.dataApi.create.callCount).toBe(1); + expect(superdeskApi.dataApi.create.args[0]).toEqual([ + 'assignments/as1/lock', + {lock_action: 'reassign'}, + ]); + + // Lock is added to the store + state = redux.getState(); + expect(selectors.locks.getLockedItems(state).assignment.as1).toEqual({ + ...testData.assignmentLocks.as1, + action: 'reassign', + }); + + // Item specific dispatches + expect(redux.dispatch.callCount).toBe(2); + expect(redux.dispatch.args[1]).toEqual([{ + type: ASSIGNMENTS.ACTIONS.LOCK_ASSIGNMENT, + payload: {assignment: lockedItem}, + }]); + + return planningApi.locks.unlockItem(testData.assignments[0]); + }) + .then(() => { + // Lock is removed from the store + state = redux.getState(); + expect(selectors.locks.getLockedItems(state).assignment.as1).toBeUndefined(); + + done(); + }); + }); + }); +}); diff --git a/client/api/tests/api_planning_test.ts b/client/api/tests/api_planning_test.ts new file mode 100644 index 000000000..18e9c7edd --- /dev/null +++ b/client/api/tests/api_planning_test.ts @@ -0,0 +1,71 @@ +import sinon from 'sinon'; +import {Store} from 'redux'; +import {cloneDeep} from 'lodash'; + +import {superdeskApi, planningApi} from '../../superdeskApi'; +import * as selectors from '../../selectors'; + +import * as testDataOriginal from '../../utils/testData'; +import {restoreSinonStub} from '../../utils/testUtils'; +import {createTestStore} from '../../utils'; + +describe('planningApi.planning', () => { + let redux: Store; + + beforeEach(() => { + redux = createTestStore(); + planningApi.redux.store = redux; + }); + + describe('addCoverageToWorkflow', () => { + afterEach(() => { + restoreSinonStub(planningApi.planning.update); + }); + + it('updates the planning item and notifies end user', (done) => { + const original = cloneDeep(testDataOriginal.plannings[0]); + const coverage = original.coverages[0]; + + sinon.stub(planningApi.planning, 'update').callsFake((original, updates) => Promise.resolve({ + ...original, + ...updates, + })); + planningApi.planning.coverages.addCoverageToWorkflow(original, coverage, 0) + .then((updatedPlanning) => { + expect(updatedPlanning.coverages[0].workflow_status).toBe('active'); + expect(updatedPlanning.coverages[0].assigned_to.state).toBe('assigned'); + + expect(superdeskApi.ui.notify.success.callCount).toBe(1); + expect(superdeskApi.ui.notify.success.args[0]).toEqual(['Coverage added to workflow.']); + + const store = planningApi.redux.store.getState(); + const plannings = selectors.planning.storedPlannings(store); + + expect(plannings[original._id].coverages[0].workflow_status).toBe('active'); + expect(plannings[original._id].coverages[0].assigned_to.state).toBe('assigned'); + + done(); + }) + .catch(done.fail); + }); + + it('notified the user on failure', (done) => { + const original = cloneDeep(testDataOriginal.plannings[0]); + const coverage = original.coverages[0]; + + sinon.stub(planningApi.planning, 'update').callsFake(() => Promise.reject('Failed request')); + planningApi.planning.coverages.addCoverageToWorkflow(original, coverage, 0) + .then( + done.fail, + (error) => { + expect(error).toBe('Failed request'); + expect(superdeskApi.ui.notify.error.callCount).toBe(1); + expect(superdeskApi.ui.notify.error.args[0]).toEqual(['Failed request']); + + done(); + } + ) + .catch(done.fail); + }); + }); +}); diff --git a/client/api/vocabularies.ts b/client/api/vocabularies.ts new file mode 100644 index 000000000..3fa289c92 --- /dev/null +++ b/client/api/vocabularies.ts @@ -0,0 +1,13 @@ +import {IVocabulary} from 'superdesk-api'; +import {IPlanningAPI, IPlanningState} from '../interfaces'; +import {planningApi} from '../superdeskApi'; + +function getCustomVocabularies(): Array { + const state: IPlanningState = planningApi.redux.store.getState(); + + return state.customVocabularies; +} + +export const vocabularies: IPlanningAPI['vocabularies'] = { + getCustomVocabularies, +}; diff --git a/client/apps/Assignments/AssignmentPreview.tsx b/client/apps/Assignments/AssignmentPreview.tsx index e64469f87..b44fcd59b 100644 --- a/client/apps/Assignments/AssignmentPreview.tsx +++ b/client/apps/Assignments/AssignmentPreview.tsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {get} from 'lodash'; +import {planningApi} from '../../superdeskApi'; import {TOOLTIPS} from '../../constants'; import {gettext, assignmentUtils, lockUtils} from '../../utils'; import * as selectors from '../../selectors'; @@ -78,7 +79,7 @@ export class AssignmentPreviewComponent extends React.Component { } onUnlock() { - this.props.unlockAssignment(this.props.assignment); + planningApi.locks.unlockItem(this.props.assignment); } render() { @@ -145,7 +146,6 @@ AssignmentPreviewComponent.propTypes = { PropTypes.array, PropTypes.object, ]), - unlockAssignment: PropTypes.func, lockedItems: PropTypes.object, hideItemActions: PropTypes.bool, showFulfilAssignment: PropTypes.bool, @@ -160,7 +160,6 @@ const mapStateToProps = (state) => ({ const mapDispatchToProps = (dispatch) => ({ closePanel: () => dispatch(actions.assignments.ui.closePreview()), - unlockAssignment: (assignment) => dispatch(actions.assignments.ui.unlockAssignment(assignment)), }); export const AssignmentPreview = connect( diff --git a/client/apps/Planning/PlanningList.tsx b/client/apps/Planning/PlanningList.tsx index 22af04c36..2a77ac744 100644 --- a/client/apps/Planning/PlanningList.tsx +++ b/client/apps/Planning/PlanningList.tsx @@ -16,6 +16,7 @@ import { LIST_VIEW_TYPE, IContactItem, SORT_FIELD, + ICommonSearchParams, } from '../../interfaces'; import * as actions from '../../actions'; @@ -59,6 +60,7 @@ interface IProps { contacts: {[key: string]: IContactItem}; listViewType: LIST_VIEW_TYPE; sortField: SORT_FIELD; + currentSearch: ICommonSearchParams; openPreview(item: IEventOrPlanningItem): void; edit(item: IEventOrPlanningItem): void; @@ -92,6 +94,7 @@ const mapStateToProps = (state) => ({ contacts: selectors.general.contactsById(state), listViewType: selectors.main.getCurrentListViewType(state), sortField: selectors.main.getCurrentSortField(state), + currentSearch: selectors.main.currentSearch(state) }); const mapDispatchToProps = (dispatch) => ({ @@ -168,6 +171,7 @@ export class PlanningListComponent extends React.PureComponent { contacts, listViewType, sortField, + currentSearch } = this.props; return ( @@ -206,6 +210,7 @@ export class PlanningListComponent extends React.PureComponent { listViewType={listViewType} sortField={sortField} indexItems + searchParams={currentSearch.advancedSearch} /> ); diff --git a/client/apps/Planning/PlanningListSubNav.tsx b/client/apps/Planning/PlanningListSubNav.tsx index 7b3d05f9d..b898c7b73 100644 --- a/client/apps/Planning/PlanningListSubNav.tsx +++ b/client/apps/Planning/PlanningListSubNav.tsx @@ -11,6 +11,9 @@ import * as actions from '../../actions'; import {Button, ButtonGroup, Dropdown, SubNav, Tooltip, IconButton} from 'superdesk-ui-framework/react'; import {FilterSubnavDropdown} from '../../components/Main'; + +import {Dropdown as DropdownFromPlanning, IDropdownItem} from '../../components/UI/SubNav/Dropdown'; + import {SubNavDatePicker} from './SubNavDatePicker'; import {IUser} from 'superdesk-api'; @@ -142,14 +145,9 @@ class PlanningListSubNavComponent extends React.Component { } render() { + const {SelectUser} = superdeskApi.components; + let newOption = {_id: null, display_name: 'ALL'}; - let list = [newOption, ...this.props.users]; - const userList = list.map((user) => ({ - label: user.display_name, - onSelect: () => { - this.filterCoverageUser(user); - } - })); const {gettext} = superdeskApi.localization; const {currentStartFilter} = this.props; let intervalText: string; @@ -185,25 +183,42 @@ class PlanningListSubNavComponent extends React.Component { return (
- + + + {this.props.activefilter == PLANNING_VIEW.EVENTS + ? ' ' + : ( + <> + + {gettext('Assigned to:')} + + +
+
{/** empty div needed so styles above don't affect SelectUser component */} + user._id == this.props.coverageUser + )?._id ?? null} + autoFocus={false} + onSelect={(user) => { + if (user != null) { + this.filterCoverageUser(user); + } else { + this.filterCoverageUser(newOption); + } + }} + horizontalSpacing={true} + clearable={true} + /> +
+
+ + ) + }
- {this.props.activefilter == PLANNING_VIEW.EVENTS ? ' ' : ( -
- {gettext('Assigned Coverages Items :')} - - - {this.props.users.find( - (user) => user._id == this.props.coverageUser)?.display_name ?? gettext('ALL')} - - - - -
- )} - - + {this.props.listViewType === LIST_VIEW_TYPE.LIST ? (
{ render() { return ( - + { location: { disableAddLocation: false, }, + priority: { + multiple: true, + defaultValue: [], + }, }, null, this.props.enabledField diff --git a/client/components/Assignments/AssignmentItem/AssignmentItem_test.tsx b/client/components/Assignments/AssignmentItem/AssignmentItem_test.tsx index 87994f1ce..d61202109 100644 --- a/client/components/Assignments/AssignmentItem/AssignmentItem_test.tsx +++ b/client/components/Assignments/AssignmentItem/AssignmentItem_test.tsx @@ -8,7 +8,7 @@ import {Provider} from 'react-redux'; import * as helpers from '../../tests/helpers'; import {cloneDeep} from 'lodash'; import {AbsoluteDate} from '../../AbsoluteDate'; -import {UserAvatar} from '../../UserAvatar'; +import {UserAvatarWithMargin} from '../../UserAvatar'; describe('assignments', () => { describe('components', () => { @@ -101,7 +101,7 @@ describe('assignments', () => { const wrapper = getMountedWrapper(); expect(wrapper.find('.icon-time').length).toBe(1); - expect(wrapper.find(UserAvatar).length).toBe(1); + expect(wrapper.find(UserAvatarWithMargin).length).toBe(1); expect(wrapper.find(AbsoluteDate).length).toBe(1); }); diff --git a/client/components/Assignments/AssignmentItem/index.tsx b/client/components/Assignments/AssignmentItem/index.tsx index 7b70d304c..57b1a018d 100644 --- a/client/components/Assignments/AssignmentItem/index.tsx +++ b/client/components/Assignments/AssignmentItem/index.tsx @@ -20,7 +20,7 @@ import {ASSIGNMENTS, CLICK_DELAY} from '../../../constants'; import {getAssignmentTypeInfo} from '../../../utils/assignments'; import {Menu} from 'superdesk-ui-framework/react'; -import {UserAvatar} from '../../'; +import {UserAvatarWithMargin} from '../../../components/UserAvatar'; import {Item, Border, Column, Row} from '../../UI/List'; import {getComponentForField, getAssignmentsListView} from './fields'; @@ -217,14 +217,7 @@ export class AssignmentItem extends React.Component { return ( - + ); } diff --git a/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreview.tsx b/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreview.tsx index b7e3c3a87..9ce0d4ef2 100644 --- a/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreview.tsx +++ b/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreview.tsx @@ -4,26 +4,23 @@ import {get} from 'lodash'; import {superdeskApi} from '../../../superdeskApi'; import { IAssignmentItem, - IPlanningItem, - ICoveragePlanningDetails, ICoverageFormProfile, - IPlanningFormProfile, + ICoveragePlanningDetails, IFile, + IPlanningFormProfile, + IPlanningItem, + PREVIEW_PANEL, } from '../../../interfaces'; -import {stringUtils, assignmentUtils, planningUtils} from '../../../utils'; +import {assignmentUtils, planningUtils} from '../../../utils'; -import {InternalNoteLabel} from '../../'; import {ContactsPreviewList} from '../../Contacts'; import {Row} from '../../UI/Preview'; import {FileReadOnlyList} from '../../UI'; +import {previewGroupToProfile, renderFieldsForPanel} from '../../fields'; interface IProps { assignment: IAssignmentItem; - keywords: Array<{ - qcode: string; - name: string; - }>; coverageFormProfile: ICoverageFormProfile; planningFormProfile: IPlanningFormProfile; planningItem: IPlanningItem; @@ -36,7 +33,6 @@ export class AssignmentPreview extends React.PureComponent { const {gettext} = superdeskApi.localization; const { assignment, - keywords, coverageFormProfile, planningFormProfile, planningItem, @@ -45,86 +41,50 @@ export class AssignmentPreview extends React.PureComponent { } = this.props; const planning: Partial = assignment?.planning ?? {}; - - const keywordString = get(planning, 'keyword.length', 0) > 0 ? - planning.keyword - .map((qcode) => get(keywords.find((k) => k.qcode === qcode), 'name') || qcode) - .join(', ') - : '-'; - - const placeText = get(planningItem, 'place.length', 0) > 0 ? - planningItem.place.map((c) => c.name).join(', ') : '-'; - - const categoryText = get(planningItem, 'anpa_category.length', 0) > 0 ? - planningItem.anpa_category.map((c) => c.name).join(', ') : '-'; - - const subjectText = get(planningItem, 'subject.length', 0) > 0 ? - planningItem.subject.map((s) => s.name).join(', ') : '-'; - const contactId = get(assignment, 'assigned_to.contact') ? assignment.assigned_to.contact : get(planning, 'contact_info'); - const showXMPFiles = planningUtils.showXMPFileUIControl(assignment); return (
- - - - - - - - - - - - - -

{stringUtils.convertNewlineToBreak(planning.internal_note || '-')}

-
+ {contactId == null ? null : ( + + + + )} + + {renderFieldsForPanel( + 'form-preview', + { + ...previewGroupToProfile(PREVIEW_PANEL.ASSIGNMENT, coverageFormProfile, false, true), + ...previewGroupToProfile(PREVIEW_PANEL.ASSIGNMENT, planningFormProfile, false, true), + }, + { + item: { + coverage: planning, + planning: planningItem, + }, + language: planning.language, + }, + { + contact_info: {field: 'coverage'}, + language: {field: 'coverage.language'}, + slugline: {field: 'coverage.slugline'}, + place: {field: 'planning.place'}, + anpa_category: {field: 'planning.anpa_category'}, + subject: {field: 'planning.subject'}, + genre: {field: 'coverage.genre'}, + keyword: {field: 'coverage.keyword'}, + ednote: {field: 'coverage.ednote', renderEmpty: true}, + internal_note: {field: 'coverage.internal_note', renderEmpty: true}, + }, + )} ', () => { @@ -57,8 +55,6 @@ describe('', () => { expect(wrapper.childAt(0).type()).toEqual(AssignmentPreviewHeader); expect(wrapper.childAt(1).hasClass('AssignmentPreview__coverage')).toBe(true); - expect(wrapper.childAt(2).hasClass('AssignmentPreview__planning')).toBe(true); - expect(wrapper.childAt(3).hasClass('AssignmentPreview__event')).toBe(true); expect(wrapper.find(ItemActionsMenu).length).toBe(1); wrapper = getWrapper({ @@ -71,8 +67,6 @@ describe('', () => { expect(wrapper.childAt(0).type()).toEqual(AssignmentPreviewHeader); expect(wrapper.childAt(1).hasClass('AssignmentPreview__fulfil')).toBe(true); expect(wrapper.childAt(2).hasClass('AssignmentPreview__coverage')).toBe(true); - expect(wrapper.childAt(3).hasClass('AssignmentPreview__planning')).toBe(true); - expect(wrapper.childAt(4).hasClass('AssignmentPreview__event')).toBe(true); expect(wrapper.find(ItemActionsMenu).length).toBe(0); }); @@ -188,35 +182,4 @@ describe('', () => { expect(wrapper.find(AssignmentPreview).length).toBe(1); expect(wrapper.find(LockContainer).length).toBe(0); }); - - it('renders Planning preview', () => { - const mountWrapper = getWrapper(); - let wrapper = mountWrapper.find('.AssignmentPreview'); - let toggle = new helpers.toggleBox(wrapper.childAt(2)); - - expect(toggle.title()).toBe('Planning'); - expect(toggle.isOpen()).toBe(false); - toggle.click(); - - wrapper = mountWrapper.find('.AssignmentPreview'); - toggle = new helpers.toggleBox(wrapper.childAt(2)); - expect(toggle.isOpen()).toBe(true); - expect(toggle.find(PlanningPreview).length).toBe(1); - }); - - it('renders Event preview', () => { - assignment.planning_item = 'p2'; - const mountWrapper = getWrapper(); - let wrapper = mountWrapper.find('.AssignmentPreview'); - let toggle = new helpers.toggleBox(wrapper.childAt(3)); - - expect(toggle.title()).toBe('Event'); - expect(toggle.isOpen()).toBe(false); - toggle.click(); - - wrapper = mountWrapper.find('.AssignmentPreview'); - toggle = new helpers.toggleBox(wrapper.childAt(3)); - expect(toggle.isOpen()).toBe(true); - expect(toggle.find(EventPreview).length).toBe(1); - }); }); diff --git a/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreviewHeader.tsx b/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreviewHeader.tsx index 48c863eda..93fc66a50 100644 --- a/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreviewHeader.tsx +++ b/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreviewHeader.tsx @@ -9,7 +9,6 @@ import {assignmentUtils, planningUtils, gettext, stringUtils} from '../../../uti import {Item, Column, Row} from '../../UI/List'; import {ContentBlock, ContentBlockInner, Tools} from '../../UI/SidePanel'; import { - UserAvatar, AbsoluteDate, PriorityLabel, StateLabel, @@ -18,6 +17,7 @@ import { ItemActionsMenu, Label, } from '../../'; +import {UserAvatar} from '../../../components/UserAvatar'; import {TO_BE_CONFIRMED_FIELD} from '../../../constants'; export const AssignmentPreviewHeader = ({ @@ -77,10 +77,7 @@ export const AssignmentPreviewHeader = ({ ) diff --git a/client/components/Assignments/AssignmentPreviewContainer/EventPreview.tsx b/client/components/Assignments/AssignmentPreviewContainer/EventPreview.tsx deleted file mode 100644 index 5ef3c76e4..000000000 --- a/client/components/Assignments/AssignmentPreviewContainer/EventPreview.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import {get} from 'lodash'; - -import {appConfig} from 'appConfig'; - -import {gettext, stringUtils, timeUtils} from '../../../utils'; -import {Datetime} from '../../'; -import {Location} from '../../Location'; -import {FileReadOnlyList} from '../../UI'; -import {Row} from '../../UI/Preview'; -import {LinkInput} from '../../UI/Form'; -import {ContactsPreviewList} from '../../Contacts'; - -export const EventPreview = ({item, formProfile, createLink, files}) => { - if (!item) { - return null; - } - - const location = get(item, 'location', {}); - const locationName = get(location, 'name'); - const formattedAddress = get(location, 'formatted_address', ''); - const contacts = get(item, 'event_contact_info') || []; - const isRemoteTimeZone = timeUtils.isEventInDifferentTimeZone(item); - const locationDetails = get(location, 'details[0]'); - - return ( -
- - - - - - - )} - /> - - - )} - /> - - - -
- -
-
- - - - - {contacts.length > 0 ? ( - - ) : ( -
-
- )} -
- - - - - - - - - - - {get(item, 'links.length', 0) && ( -
    - {get(item, 'links').map((link, index) => ( -
  • - -
  • - ) - )} -
- ) - || -

{gettext('No external links added.')}

- } -
-
- ); -}; - -EventPreview.propTypes = { - item: PropTypes.object, - formProfile: PropTypes.object, - createLink: PropTypes.func, - files: PropTypes.object, -}; diff --git a/client/components/Assignments/AssignmentPreviewContainer/PlanningPreview.tsx b/client/components/Assignments/AssignmentPreviewContainer/PlanningPreview.tsx deleted file mode 100644 index 6b7a733a5..000000000 --- a/client/components/Assignments/AssignmentPreviewContainer/PlanningPreview.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import {gettext, stringUtils, getItemInArrayById} from '../../../utils'; -import {getUserInterfaceLanguageFromCV} from '../../../utils/users'; - -import {get, keyBy} from 'lodash'; -import {Label} from '../../'; -import {ColouredValueInput} from '../../UI/Form'; -import {AgendaNameList} from '../../Agendas'; -import {Row} from '../../UI/Preview'; - -export const PlanningPreview = ({item, formProfile, agendas, urgencies}) => { - const agendaMap = keyBy(agendas, '_id'); - const agendaAssigned = (get(item, 'agendas') || []).map((agendaId) => get(agendaMap, agendaId)); - const urgency = getItemInArrayById(urgencies, item.urgency, 'qcode'); - - return ( -
- - {get(agendaAssigned, 'length', 0) > 0 ? ( - - ) : ( -

-

- )} -
- - - - - - - - - - - - - - - - - - - -
- ); -}; - -PlanningPreview.propTypes = { - item: PropTypes.object, - formProfile: PropTypes.object, - agendas: PropTypes.array, - urgencies: PropTypes.array, -}; diff --git a/client/components/Assignments/AssignmentPreviewContainer/index.tsx b/client/components/Assignments/AssignmentPreviewContainer/index.tsx index f2b36e0a6..72ecb77a2 100644 --- a/client/components/Assignments/AssignmentPreviewContainer/index.tsx +++ b/client/components/Assignments/AssignmentPreviewContainer/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import * as React from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {get} from 'lodash'; @@ -10,13 +10,14 @@ import {ASSIGNMENTS, WORKSPACE} from '../../../constants'; import {AssignmentPreviewHeader} from './AssignmentPreviewHeader'; import {AssignmentPreview} from './AssignmentPreview'; -import {PlanningPreview} from './PlanningPreview'; -import {EventPreview} from './EventPreview'; -import {ToggleBox, Button} from '../../UI'; +import {Button} from '../../UI'; import {ContentBlock, ContentBlockInner} from '../../UI/SidePanel'; +import {RelatedPlannings} from '../../RelatedPlannings'; +import {EventMetadata} from '../../Events'; + class AssignmentPreviewContainerComponent extends React.Component { - componentWillMount() { + componentDidMount() { if (eventUtils.shouldFetchFilesForEvent(this.props.eventItem)) { this.props.fetchEventFiles(this.props.eventItem); } @@ -75,12 +76,8 @@ class AssignmentPreviewContainerComponent extends React.Component { desks, planningItem, eventItem, - urgencyLabel, priorities, - urgencies, - keywords, formProfile, - agendas, hideAvatar, currentWorkspace, contentTypes, @@ -130,7 +127,6 @@ class AssignmentPreviewContainerComponent extends React.Component { - - - - - - {eventItem && ( - - - +

+ {gettext('Associated Event')} +

+
)} + + +

+ {gettext('Planning')} +

+ +
); } @@ -196,14 +187,10 @@ AssignmentPreviewContainerComponent.propTypes = { desks: PropTypes.array, planningItem: PropTypes.object, eventItem: PropTypes.object, - urgencyLabel: PropTypes.string, priorities: PropTypes.array, - urgencies: PropTypes.array, privileges: PropTypes.object, - keywords: PropTypes.array, formProfile: PropTypes.object, lockedItems: PropTypes.object, - agendas: PropTypes.array, openArchivePreview: PropTypes.func, revertAssignment: PropTypes.func, hideItemActions: PropTypes.bool, @@ -225,13 +212,9 @@ const mapStateToProps = (state) => ({ eventItem: selectors.getCurrentAssignmentEventItem(state), priorities: get(state, 'vocabularies.assignment_priority'), - urgencyLabel: selectors.vocabs.urgencyLabel(state), - urgencies: selectors.getUrgencies(state), privileges: selectors.general.privileges(state), - keywords: get(state, 'vocabularies.keywords', []), formProfile: selectors.forms.profiles(state), lockedItems: selectors.locks.getLockedItems(state), - agendas: selectors.general.agendas(state), currentWorkspace: selectors.general.currentWorkspace(state), contentTypes: selectors.general.contentTypes(state), files: selectors.general.files(state), diff --git a/client/components/ContentProfiles/FieldTab/FieldEditor.tsx b/client/components/ContentProfiles/FieldTab/FieldEditor.tsx index 2657c7769..bc5857e2d 100644 --- a/client/components/ContentProfiles/FieldTab/FieldEditor.tsx +++ b/client/components/ContentProfiles/FieldTab/FieldEditor.tsx @@ -87,6 +87,7 @@ export class FieldEditor extends React.Component { const fieldProps = { 'schema.required': {enabled: !(this.props.disableRequired || this.props.systemRequired)}, 'schema.read_only': {enabled: this.props.item.name === 'related_plannings'}, + 'schema.planning_auto_publish': {enabled: this.props.item.name === 'related_plannings'}, 'schema.field_type': {enabled: fieldType != null}, 'schema.minlength': {enabled: !disableMinMax}, 'schema.maxlength': {enabled: !disableMinMax}, @@ -99,11 +100,12 @@ export class FieldEditor extends React.Component { 'schema.default_language': {enabled: (this.props.item.name === 'language' && isMultilingual)}, 'schema.multilingual': {enabled: ( this.props.item.name === 'language' || ( - this.props.item.schema.type === 'string' && + this.props.item.schema?.type === 'string' && isMultilingual && !['language', 'location'].includes(this.props.item.name) ) )}, + 'schema.default_value': {enabled: this.props.item.name === 'priority'}, }; const noOptionsAvailable = !( Object.values(fieldProps) @@ -187,6 +189,8 @@ export class FieldEditor extends React.Component { 'schema.multilingual': {enabled: true, index: 11}, 'schema.languages': {enabled: true, index: 12}, 'schema.default_language': {enabled: true, index: 13}, + 'schema.planning_auto_publish': {enabled: true, index: 14}, + 'schema.default_value': {enabled: true, index: 11}, }, { item: this.props.item, diff --git a/client/components/Coverages/CoverageEditor/CoverageForm.tsx b/client/components/Coverages/CoverageEditor/CoverageForm.tsx index b3955ed8d..135a7b13a 100644 --- a/client/components/Coverages/CoverageEditor/CoverageForm.tsx +++ b/client/components/Coverages/CoverageEditor/CoverageForm.tsx @@ -449,6 +449,7 @@ export class CoverageFormComponent extends React.Component { this.props.value.planning?.g2_content_type === 'text' ), }, + priority: {field: 'planning.priority'}, }; const profile = editor.item.planning.getCoverageFields(); diff --git a/client/components/Coverages/CoverageEditor/CoverageFormHeader.tsx b/client/components/Coverages/CoverageEditor/CoverageFormHeader.tsx index dcc41be83..26c534fb3 100644 --- a/client/components/Coverages/CoverageEditor/CoverageFormHeader.tsx +++ b/client/components/Coverages/CoverageEditor/CoverageFormHeader.tsx @@ -8,7 +8,7 @@ import {IArticle, IDesk, IUser} from 'superdesk-api'; import {getCreator, getItemInArrayById, gettext, planningUtils, onEventCapture} from '../../../utils'; import {Item, Border, Column, Row as ListRow} from '../../UI/List'; import {Button} from '../../UI'; -import {UserAvatar} from '../../'; +import {UserAvatar} from '../../../components/UserAvatar'; import {StateLabel} from '../../StateLabel'; import * as actions from '../../../actions'; @@ -85,10 +85,8 @@ export class CoverageFormHeaderComponent extends React.PureComponent { @@ -122,10 +120,8 @@ export class CoverageFormHeaderComponent extends React.PureComponent { diff --git a/client/components/Coverages/CoverageHistory.tsx b/client/components/Coverages/CoverageHistory.tsx index ad33987c5..0504b0b62 100644 --- a/client/components/Coverages/CoverageHistory.tsx +++ b/client/components/Coverages/CoverageHistory.tsx @@ -9,7 +9,7 @@ import {stringUtils, historyUtils, getDateTimeString, getItemInArrayById, gettex import {Item, Column, Row, Border} from '../UI/List'; import {ContentBlock} from '../UI/SidePanel'; import {CollapseBox} from '../UI'; -import {CoverageIcon} from './index'; +import {CoverageIcons} from './CoverageIcons'; export class CoverageHistory extends React.Component { getHistoryActionElement(historyItem) { @@ -143,8 +143,8 @@ export class CoverageHistory extends React.Component { - ; - users: Array; - desks: Array; - contentTypes: Array; - contacts: Dictionary; - tooltipDirection?: 'top' | 'right' | 'bottom' | 'left'; // defaults to 'right' - iconWrapper?(children: React.ReactNode): React.ReactNode; -} - -export class CoverageIcon extends React.PureComponent { - render() { - const {gettext} = superdeskApi.localization; - const language = this.props.coverage.planning?.language ?? getUserInterfaceLanguageFromCV(); - const user = this.props.users.find( - (u) => u._id === this.props.coverage.assigned_to?.user, - ); - const desk = this.props.desks.find( - (d) => d._id === this.props.coverage.assigned_to?.desk, - ); - const dateFormat = appConfig.planning.dateformat; - const timeFormat = appConfig.planning.timeformat; - let provider = this.props.coverage.assigned_to?.coverage_provider?.name; - const contactId = this.props.coverage.assigned_to?.contact; - - if (contactId != null && this.props.contacts?.[contactId] != null) { - const contact = this.props.contacts[contactId]; - - provider = contact.first_name ? - `${contact.last_name}, ${contact.first_name}` : - contact.organisation; - } - - const assignmentStr = desk ? - gettext('Desk: {{ desk }}', {desk: desk.name}) : - gettext('Status: Unassigned'); - let scheduledStr = this.props.coverage.planning?.scheduled != null && dateFormat && timeFormat ? - moment(this.props.coverage.planning.scheduled).format(dateFormat + ' ' + timeFormat) : - null; - - if (this.props.coverage._time_to_be_confirmed) { - scheduledStr = moment(this.props.coverage.planning.scheduled) - .format(dateFormat + ` @ ${gettext('TBC')}`); - } - const state = getItemWorkflowStateLabel(this.props.coverage.assigned_to); - const genre = getVocabularyItemFieldTranslated( - this.props.coverage.planning?.genre, - 'name', - language - ); - const slugline = this.props.coverage.planning?.slugline ?? ''; - const contentType = getVocabularyItemFieldTranslated( - this.props.contentTypes.find( - (type) => type.qcode === this.props.coverage.planning?.g2_content_type - ), - 'name', - language - ); - const icons = ( - - - - - ); - const ContentWrapper = this.props.iconWrapper != null ? - this.props.iconWrapper : - () => icons; - - return ( - - {!contentType?.length ? null : ( - - {gettext('Type: {{ type }}', {type: contentType})}
-
- )} - {!desk ? null : ( - - {gettext('Status: {{ state }}', {state: state.label})}
-
- )} - {assignmentStr} - {!user ? null : ( - -
{gettext('User: {{ user }}', {user: user.display_name})} -
- )} - {!provider ? null : ( - -
{gettext('Provider: {{ provider }}', {provider: provider})} -
- )} - {!genre ? null : ( - -
{gettext('Genre: {{ genre }}', {genre: genre})} -
- )} - {!slugline ? null : ( - -
{gettext('Slugline: {{ slugline }}', {slugline: slugline})} -
- )} - {!scheduledStr ? null : ( - -
{gettext('Due: {{ date }}', {date: scheduledStr})} -
- )} - {(this.props.coverage.scheduled_updates ?? []).map((s) => { - if (s.planning?.scheduled != null) { - scheduledStr = dateFormat && timeFormat ? - moment(s.planning.scheduled).format(dateFormat + ' ' + timeFormat) : - null; - return ( - -
{gettext('Update Due: {{ date }}', {date: scheduledStr})} -
- ); - } - - return null; - })} - - )} - > - {ContentWrapper(icons)} -
- ); - } -} diff --git a/client/components/Coverages/CoverageIcons.tsx b/client/components/Coverages/CoverageIcons.tsx new file mode 100644 index 000000000..08aa5ce11 --- /dev/null +++ b/client/components/Coverages/CoverageIcons.tsx @@ -0,0 +1,277 @@ +import * as React from 'react'; +import moment from 'moment-timezone'; +import {getCustomAvatarContent, getUserInitials} from './../../components/UserAvatar'; +import * as config from 'appConfig'; +import {IPlanningCoverageItem, IG2ContentType, IContactItem, IPlanningConfig} from '../../interfaces'; +import {IUser, IDesk} from 'superdesk-api'; +import {superdeskApi} from '../../superdeskApi'; +import { + AvatarGroup, + ContentDivider, + Icon, + WithPopover, + Avatar, + AvatarPlaceholder, + Spacer, +} from 'superdesk-ui-framework/react'; +import {IPropsAvatarPlaceholder} from 'superdesk-ui-framework/react/components/avatar/avatar-placeholder'; +import {IPropsAvatar} from 'superdesk-ui-framework/react/components/avatar/avatar'; +import {trimStartExact} from 'superdesk-core/scripts/core/helpers/utils'; +import {getItemWorkflowStateLabel, gettext, planningUtils} from '../../utils'; +import {getVocabularyItemFieldTranslated} from '../../utils/vocabularies'; +import {getUserInterfaceLanguageFromCV} from '../../utils/users'; +import './coverage-icons.scss'; +import classNames from 'classnames'; +import {noop} from 'lodash'; + +interface IProps { + coverages: Array>; + users: Array; + desks: Array; + contentTypes: Array; + contacts?: Dictionary; + tooltipDirection?: 'top' | 'right' | 'bottom' | 'left'; // defaults to 'right' + iconWrapper?(children: React.ReactNode): React.ReactNode; +} + +const appConfig = config.appConfig as IPlanningConfig; + +export function isAvatarPlaceholder( + item: Omit | Omit +): item is Omit { + return (item as any)['kind'] != null; +} + +export function getAvatarForCoverage( + coverage: DeepPartial, + users: Array, + contentTypes: Array, + noIcon: boolean = false, +): Omit | Omit { + const user = users.find((u) => u._id === coverage.assigned_to?.user); + + const icon: {name: string; color: string} | undefined = + noIcon === true || coverage.planning?.g2_content_type == null ? undefined : { + name: trimStartExact( + planningUtils.getCoverageIcon( + planningUtils.getCoverageContentType( + coverage, + contentTypes, + ) || coverage.planning?.g2_content_type, + coverage, + ), + 'icon-', + ), + color: planningUtils.getCoverageIconColor(coverage), + }; + + if (user == null) { + const placeholder: Omit = { + kind: 'user-icon', + icon: icon, + tooltip: gettext('Unassigned'), + }; + + return placeholder; + } else { + const avatar: Omit = { + initials: getUserInitials(user.display_name), + imageUrl: user.picture_url, + displayName: user.display_name, + icon: icon, + customContent: getCustomAvatarContent(user), + }; + + return avatar; + } +} + +export class CoverageIcons extends React.PureComponent { + render() { + const {coverages, users} = this.props; + const {gettext} = superdeskApi.localization; + + return ( + ( +
+ + {this.props.coverages.map((coverage, i) => { + const language = coverage.planning?.language ?? getUserInterfaceLanguageFromCV(); + const desk = this.props.desks.find( + (d) => d._id === coverage.assigned_to?.desk, + ); + const dateFormat = appConfig.planning.dateformat; + const timeFormat = appConfig.planning.timeformat; + + const assignmentStr = desk ? + gettext('Desk: {{ desk }}', {desk: desk.name}) : + gettext('Status: Unassigned'); + let scheduledStr = coverage.planning?.scheduled != null && dateFormat && timeFormat ? + moment(coverage.planning.scheduled).format(dateFormat + ' ' + timeFormat) : + null; + + if (coverage._time_to_be_confirmed) { + scheduledStr = moment(coverage.planning.scheduled) + .format(dateFormat + ` @ ${gettext('TBC')}`); + } + const slugline = coverage.planning?.slugline ?? ''; + const contentType = getVocabularyItemFieldTranslated( + this.props.contentTypes.find( + (type) => type.qcode === coverage.planning?.g2_content_type + ), + 'name', + language + ); + + const maybeAvatar = getAvatarForCoverage( + coverage, + users, + this.props.contentTypes, + true, + ); + const state = getItemWorkflowStateLabel(coverage.assigned_to); + + const iconTooltipInfo: Array = [ + gettext('Type: {{ type }}', {type: contentType}), + ]; + + if (desk != null) { + iconTooltipInfo.push(gettext('Status: {{ state }}', {state: state.label})); + } + + return ( + + +
+ + + +
+ +
+
+ + {gettext('Due:')} + + {scheduledStr} + + +
+ + {(coverage.scheduled_updates ?? []).map((s) => { + if (s.planning?.scheduled != null) { + const scheduledStr2 = dateFormat && timeFormat ? + moment(s.planning.scheduled) + .format(dateFormat + ' ' + timeFormat) : + null; + + return ( +
+ + {gettext('Update Due:')} + + {scheduledStr2} + + +
+ ); + } + + return null; + })} + + +
{assignmentStr}
+ +
+ +
+ + {!slugline ? null : ( +
+ + {slugline} + +
+ )} +
+
+
+ +
+ { + isAvatarPlaceholder(maybeAvatar) + ? ( + + ) + : ( + + ) + } +
+
+ ); + })} +
+
+ )} + > + {(onToggle) => ( +
{ + event.stopPropagation(); + onToggle(event.target as HTMLElement); + }} + > + getAvatarForCoverage(coverage, users, this.props.contentTypes), + )} + onClick={noop} // can move code from onClick, because event is not available + /> +
+ )} +
+ ); + } +} diff --git a/client/components/Coverages/CoverageItem.tsx b/client/components/Coverages/CoverageItem.tsx index f8aace117..100118b46 100644 --- a/client/components/Coverages/CoverageItem.tsx +++ b/client/components/Coverages/CoverageItem.tsx @@ -19,8 +19,7 @@ import {getUserInterfaceLanguageFromCV} from '../../utils/users'; import {Item, Column, Row, Border, ActionMenu} from '../UI/List'; import {StateLabel, InternalNoteLabel} from '../../components'; -import {CoverageIcon} from './CoverageIcon'; -import {UserAvatar} from '../UserAvatar'; +import {CoverageIcons} from './CoverageIcons'; interface IProps { coverage: IPlanningCoverageItem; @@ -166,20 +165,12 @@ export class CoverageItemComponent extends React.Component { return ( - {this.state.userAssigned ? ( - - ) : ( - - )} + ); } @@ -187,12 +178,6 @@ export class CoverageItemComponent extends React.Component { renderFirstRow() { return ( - {this.state.displayContentType} diff --git a/client/components/Coverages/CoveragePreview/index.tsx b/client/components/Coverages/CoveragePreview/index.tsx index 02596d623..5dd528259 100644 --- a/client/components/Coverages/CoveragePreview/index.tsx +++ b/client/components/Coverages/CoveragePreview/index.tsx @@ -33,10 +33,10 @@ interface IProps { desks: Array; newsCoverageStatus: Array; formProfile: ICoverageFormProfile; - noOpen: boolean; - active: boolean; + noOpen?: boolean; + active?: boolean; scrollInView: boolean; - onClick(): void; + onClick?(): void; inner: boolean; index: number; item: IPlanningItem; @@ -137,11 +137,20 @@ export class CoveragePreview extends React.PureComponent { 'form-preview', previewGroupToProfile(PREVIEW_PANEL.COVERAGE, formProfile), { - item: coverage.planning, - language: getUserInterfaceLanguageFromCV(), + item: coverage, + language: coverage.planning.language ?? getUserInterfaceLanguageFromCV(), renderEmpty: true, }, - {} + { + language: {field: 'planning.language', enabled: false}, + slugline: {field: 'planning.slugline'}, + ednote: {field: 'planning.ednote'}, + keyword: {field: 'planning.keyword'}, + internal_note: {field: 'planning.internal_note'}, + g2_content_type: {field: 'planning.g2_content_type'}, + genre: {field: 'planning.genre'}, + flags: {field: 'planning.flags'}, + } )} {planningUtils.showXMPFileUIControl(coverage) && ( @@ -158,13 +167,6 @@ export class CoveragePreview extends React.PureComponent { )} - {get(formProfile, 'editor.genre.enabled') && coverage.planning.genre && ( - - )} - {get(formProfile, 'editor.files.enabled') && ( { )} - - - {get(formProfile, 'editor.scheduled.enabled') && ( - - )} - - {get(formProfile, 'editor.flags') && get(coverage, 'flags.no_content_linking') && ( - - - {gettext('Do not link content updates')} - - - )} - {planningAllowScheduledUpdates && ( {(coverage.scheduled_updates || []).map((s, i) => ( @@ -230,6 +212,7 @@ export class CoveragePreview extends React.PureComponent { scrollInView={scrollInView} forceScroll={active} inner={inner} + scrollIntoViewOptions={{behavior: 'smooth'}} /> ); } diff --git a/client/components/Coverages/coverage-icons.scss b/client/components/Coverages/coverage-icons.scss new file mode 100644 index 000000000..02ba64789 --- /dev/null +++ b/client/components/Coverages/coverage-icons.scss @@ -0,0 +1,20 @@ +.coverages-popup { + background-color: var(--color-dropdown-menu-Bg); + border-radius: var(--b-radius--medium); + padding: 1.5rem; + box-shadow: var(--sd-shadow__dropdown); + max-height: 100%; + overflow: auto; +} + +.coverages-popup__text-light { + color: var(--color-text-light); + font-weight: 400; + line-height: 1.4rem; +} + +.coverages-popup__text-bold { + color: var(--color-text); + font-weight: 500; + line-height: 1.4rem; +} diff --git a/client/components/Coverages/index.ts b/client/components/Coverages/index.ts index 06f24486c..4bde7d759 100644 --- a/client/components/Coverages/index.ts +++ b/client/components/Coverages/index.ts @@ -2,7 +2,6 @@ export {CoverageArrayInput} from './CoverageArrayInput'; export {CoverageEditor} from './CoverageEditor'; export {CoverageItem} from './CoverageItem'; export {CoveragePreview} from './CoveragePreview/index'; -export {CoverageIcon} from './CoverageIcon'; export {CoverageAddButton} from './CoverageAddButton'; export {CoverageHistory} from './CoverageHistory'; export {ScheduledUpdate} from './ScheduledUpdate'; diff --git a/client/components/CustomVocabulariesFields.tsx b/client/components/CustomVocabulariesFields.tsx deleted file mode 100644 index beec2fe69..000000000 --- a/client/components/CustomVocabulariesFields.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import * as React from 'react'; -import {get} from 'lodash'; - -import {IVocabulary} from 'superdesk-api'; -import {superdeskApi} from '../superdeskApi'; - -import {getUserInterfaceLanguageFromCV} from '../utils/users'; -import {SelectMetaTermsInput, Field} from './UI/Form'; - -interface IProps { - testId?: string; - customVocabularies: Array; - fieldProps: any; - popupProps: any; - onFocusDetails?(): void; - popupContainer?(): HTMLElement; -} - -export default class CustomVocabulariesFields extends React.PureComponent { - render() { - const {gettext} = superdeskApi.localization; - const { - customVocabularies, - fieldProps, - onFocusDetails, - popupProps, - popupContainer, - testId, - } = this.props; - - const { - errors, - diff, - } = fieldProps; - const language = get(diff, 'language') || getUserInterfaceLanguageFromCV(); - - return customVocabularies - .map((cv) => ( - Object.assign({scheme: cv._id}, item))} - defaultValue={[]} - error={get(errors, cv._id)} - {...fieldProps} - onFocus={onFocusDetails} - scheme={cv._id} - popupContainer={popupContainer} - language={language} - noMargin={true} - {...popupProps} - /> - )); - } -} diff --git a/client/components/Editor/EditorGroup.tsx b/client/components/Editor/EditorGroup.tsx index db0f41931..2addcbcba 100644 --- a/client/components/Editor/EditorGroup.tsx +++ b/client/components/Editor/EditorGroup.tsx @@ -117,6 +117,10 @@ export class EditorGroup extends React.PureComponent implements IEditorR const group = this.props.group; const testId = `editor--group__${group.id}`; const profile = this.getProfile(); + + const editor = planningApi.editor(this.props.editorType); + const coverageProfile = editor.item.planning.getCoverageFields(); + const renderedFields = renderFieldsForPanel( 'editor', profile, @@ -126,7 +130,8 @@ export class EditorGroup extends React.PureComponent implements IEditorR null, 'enabled', this.editorApi.dom.fields, - this.props.schema + this.props.schema, + coverageProfile, ); return group.useToggleBox ? ( diff --git a/client/components/Editor/bookmarks/CoveragesBookmark.tsx b/client/components/Editor/bookmarks/CoveragesBookmark.tsx index 1b611033d..ca00809df 100644 --- a/client/components/Editor/bookmarks/CoveragesBookmark.tsx +++ b/client/components/Editor/bookmarks/CoveragesBookmark.tsx @@ -17,10 +17,12 @@ import {planningApi, superdeskApi} from '../../../superdeskApi'; import * as selectors from '../../../selectors'; -import {Icon} from 'superdesk-ui-framework/react'; +import {Avatar, AvatarPlaceholder, Icon} from 'superdesk-ui-framework/react'; import {Row} from '../../UI/Form'; import * as List from '../../UI/List'; -import {CoverageEditor, CoverageItem, CoverageIcon} from '../../Coverages'; +import {CoverageEditor, CoverageItem} from '../../Coverages'; +import {getAvatarForCoverage, isAvatarPlaceholder} from '../../Coverages/CoverageIcons'; + interface IProps extends IBookmarkProps { users: Array; @@ -66,34 +68,37 @@ class CoveragesBookmarkComponent extends React.Component { } renderForPanel() { - return (this.props.item?.coverages ?? []).map((coverage) => ( - ( - - )} - /> - )); + return (this.props.item?.coverages ?? []).map((coverage) => { + const {users} = this.props; + const maybeAvatar = getAvatarForCoverage(coverage, users, this.props.contentTypes); + + return ( + + ); + }); } renderForPopup() { @@ -150,9 +155,9 @@ class CoveragesBookmarkComponent extends React.Component { return null; } - return this.props.editorType === EDITOR_TYPE.POPUP ? - this.renderForPopup() : - this.renderForPanel(); + return this.props.editorType === EDITOR_TYPE.POPUP + ? this.renderForPopup() + : this.renderForPanel(); } } diff --git a/client/components/Events/EventDateTime.tsx b/client/components/Events/EventDateTime.tsx index 8730fa082..3673d0b2e 100644 --- a/client/components/Events/EventDateTime.tsx +++ b/client/components/Events/EventDateTime.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import moment from 'moment'; import {superdeskApi} from '../../superdeskApi'; import {IEventItem} from '../../interfaces'; @@ -20,8 +19,8 @@ export class EventDateTime extends React.PureComponent { render() { const {gettext} = superdeskApi.localization; const {item, ignoreAllDay, displayLocalTimezone} = this.props; - const start = moment(item.dates.start); - const end = moment(item.dates.end); + const start = eventUtils.getStartDate(item); + const end = eventUtils.getEndDate(item); const isAllDay = eventUtils.isEventAllDay(start, end); const multiDay = !eventUtils.isEventSameDay(start, end); const isRemoteTimeZone = timeUtils.isEventInDifferentTimeZone(item); diff --git a/client/components/Events/EventEditor/index.tsx b/client/components/Events/EventEditor/index.tsx index a82a71092..45cbafa12 100644 --- a/client/components/Events/EventEditor/index.tsx +++ b/client/components/Events/EventEditor/index.tsx @@ -138,10 +138,7 @@ class EventEditorComponent extends React.PureComponent { @@ -216,6 +213,7 @@ class EventEditorComponent extends React.PureComponent { addPlanningItem: editor.item.events.addPlanningItem, removePlanningItem: editor.item.events.removePlanningItem, updatePlanningItem: editor.item.events.updatePlanningItem, + addCoverageToWorkflow: editor.item.events.addCoverageToWorkflow, }, }} /> diff --git a/client/components/Events/EventItem.tsx b/client/components/Events/EventItem.tsx index 1729d3a4d..f61570239 100644 --- a/client/components/Events/EventItem.tsx +++ b/client/components/Events/EventItem.tsx @@ -16,11 +16,13 @@ import { isItemExpired, isItemPosted, onEventCapture, + lockUtils, } from '../../utils'; import {renderFields} from '../fields'; import {CreatedUpdatedColumn} from '../UI/List/CreatedUpdatedColumn'; import {EventDateTimeColumn} from './EventDateTimeColumn'; import * as actions from '../../actions'; +import {getUserInterfaceLanguageFromCV} from '../../utils/users'; interface IState { hover: boolean; @@ -42,7 +44,9 @@ class EventItemComponent extends React.Component { shouldComponentUpdate(nextProps: Readonly, nextState: Readonly) { return isItemDifferent(this.props, nextProps) || this.state.hover !== nextState.hover || - this.props.minTimeWidth !== nextProps.minTimeWidth; + this.props.minTimeWidth !== nextProps.minTimeWidth || + this.props.lockedItems != nextProps.lockedItems || + this.props.filterLanguage !== nextProps.filterLanguage; } onItemHoverOn() { @@ -154,6 +158,7 @@ class EventItemComponent extends React.Component { active, refNode, listViewType, + filterLanguage } = this.props; if (!item) { @@ -161,7 +166,7 @@ class EventItemComponent extends React.Component { } const hasPlanning = eventUtils.eventHasPlanning(item); - const isItemLocked = eventUtils.isEventLocked(item, lockedItems); + const isItemLocked = lockUtils.isItemLocked(item, lockedItems); const showRelatedPlanningLink = activeFilter === PLANNING_VIEW.COMBINED && hasPlanning; let borderState: 'locked' | 'active' | false = false; @@ -175,6 +180,7 @@ class EventItemComponent extends React.Component { const isExpired = isItemExpired(item); const secondaryFields = get(listFields, 'event.secondary_fields', EVENTS.LIST.SECONDARY_FIELDS); + const language = filterLanguage || item.language || getUserInterfaceLanguageFromCV(); return ( { {renderFields(get(listFields, 'event.primary_fields', - EVENTS.LIST.PRIMARY_FIELDS), item)} + EVENTS.LIST.PRIMARY_FIELDS), item, {}, language)} diff --git a/client/components/Events/EventMetadata/RelatedEventListItem.tsx b/client/components/Events/EventMetadata/RelatedEventListItem.tsx index 105abe51f..79521ce94 100644 --- a/client/components/Events/EventMetadata/RelatedEventListItem.tsx +++ b/client/components/Events/EventMetadata/RelatedEventListItem.tsx @@ -5,7 +5,7 @@ import {IEventItem, ILockedItems} from '../../../interfaces'; import {superdeskApi} from '../../../superdeskApi'; import {ICON_COLORS} from '../../../constants'; -import {eventUtils} from '../../../utils'; +import {eventUtils, lockUtils} from '../../../utils'; import * as selectors from '../../../selectors'; import * as List from '../../UI/List'; @@ -33,7 +33,7 @@ const mapStateToProps = (state) => ({ class RelatedEventListItemComponent extends React.PureComponent { render() { - const isItemLocked = eventUtils.isEventLocked( + const isItemLocked = lockUtils.isItemLocked( this.props.item, this.props.lockedItems ); diff --git a/client/components/Events/EventMetadata/index.tsx b/client/components/Events/EventMetadata/index.tsx index f91a42855..0897e4761 100644 --- a/client/components/Events/EventMetadata/index.tsx +++ b/client/components/Events/EventMetadata/index.tsx @@ -168,7 +168,7 @@ class EventMetadataComponent extends React.PureComponent { previewGroupToProfile(PREVIEW_PANEL.ASSOCIATED_EVENT, this.props.formProfile), { item: event, - language: getUserInterfaceLanguageFromCV(), + language: event.language ?? getUserInterfaceLanguageFromCV(), renderEmpty: true, schema: this.props.formProfile?.schema, profile: this.props.formProfile, @@ -242,6 +242,7 @@ class EventMetadataComponent extends React.PureComponent { onOpen={onOpen} onClick={onClick} forceScroll={forceScroll} + scrollIntoViewOptions={{behavior: 'smooth'}} /> ); } diff --git a/client/components/Events/EventPreviewContent.tsx b/client/components/Events/EventPreviewContent.tsx index 81ac5a4d0..dbff0384f 100644 --- a/client/components/Events/EventPreviewContent.tsx +++ b/client/components/Events/EventPreviewContent.tsx @@ -32,8 +32,8 @@ interface IProps { files: {[key: string]: IFile}; } -const mapStateToProps = (state) => ({ - item: selectors.events.getEventPreviewRelatedDetails(state), +const mapStateToProps = (state, ownProps) => ({ + item: selectors.events.getEventPreviewRelatedDetails(state) || ownProps.item, privileges: selectors.general.privileges(state), users: selectors.general.users(state), desks: selectors.general.desks(state), @@ -96,7 +96,7 @@ export class EventPreviewContentComponent extends React.PureComponent { previewGroupToProfile(PREVIEW_PANEL.EVENT, formProfile), { item: item, - language: getUserInterfaceLanguageFromCV(), + language: item.language ?? getUserInterfaceLanguageFromCV(), renderEmpty: true, schema: formProfile.schema, profile: formProfile, diff --git a/client/components/Events/EventPreviewHeader.tsx b/client/components/Events/EventPreviewHeader.tsx index 0d7935b7a..8cbce62c8 100644 --- a/client/components/Events/EventPreviewHeader.tsx +++ b/client/components/Events/EventPreviewHeader.tsx @@ -1,12 +1,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; + +import {planningApi} from '../../superdeskApi'; + import {Tools} from '../UI/SidePanel'; import {ItemActionsMenu, LockContainer, ItemIcon} from '../index'; import {eventUtils, lockUtils, actionUtils} from '../../utils'; import {PRIVILEGES, EVENTS, ICON_COLORS} from '../../constants'; import * as selectors from '../../selectors'; -import * as actions from '../../actions'; import {get} from 'lodash'; export class EventPreviewHeaderComponent extends React.PureComponent { @@ -18,7 +20,6 @@ export class EventPreviewHeaderComponent extends React.PureComponent { item, lockedItems, session, - onUnlock, itemActionDispatches, hideItemActions, } = this.props; @@ -63,7 +64,7 @@ export class EventPreviewHeaderComponent extends React.PureComponent { calendars, }) : null; const lockedUser = lockUtils.getLockedUser(item, lockedItems, users); - const lockRestricted = eventUtils.isEventLockRestricted(item, session, lockedItems); + const lockRestricted = lockUtils.isLockRestricted(item, session, lockedItems); const unlockPrivilege = !!privileges[PRIVILEGES.EVENT_MANAGEMENT]; return ( @@ -81,7 +82,7 @@ export class EventPreviewHeaderComponent extends React.PureComponent { users={users} showUnlock={unlockPrivilege} withLoggedInfo={true} - onUnlock={onUnlock.bind(null, item)} + onUnlock={planningApi.locks.unlockItem.bind(null, item)} small={false} noMargin={true} /> @@ -107,7 +108,6 @@ EventPreviewHeaderComponent.propTypes = { itemActionDispatches: PropTypes.object, hideItemActions: PropTypes.bool, duplicateEvent: PropTypes.func, - onUnlock: PropTypes.func, calendars: PropTypes.array, }; @@ -121,7 +121,6 @@ const mapStateToProps = (state, ownProps) => ({ }); const mapDispatchToProps = (dispatch) => ({ - onUnlock: (event) => dispatch(actions.locks.unlock(event)), itemActionDispatches: actionUtils.getActionDispatches({dispatch: dispatch, eventOnly: true}), }); diff --git a/client/components/Events/EventScheduleSummary/index.tsx b/client/components/Events/EventScheduleSummary/index.tsx index 75a7779f5..eb8e6ee59 100644 --- a/client/components/Events/EventScheduleSummary/index.tsx +++ b/client/components/Events/EventScheduleSummary/index.tsx @@ -20,8 +20,9 @@ export const EventScheduleSummary = ({ forUpdating = false, useEventTimezone = false }: IProps) => { - if (!event) + if (!event) { return null; + } const eventSchedule: IEventItem['dates'] = get(event, 'dates', {}); const doesRepeat = get(eventSchedule, 'recurring_rule', null) !== null; diff --git a/client/components/ItemActionConfirmation/forms/assignCalendarForm.tsx b/client/components/ItemActionConfirmation/forms/assignCalendarForm.tsx index 459b5ba7d..64de141ff 100644 --- a/client/components/ItemActionConfirmation/forms/assignCalendarForm.tsx +++ b/client/components/ItemActionConfirmation/forms/assignCalendarForm.tsx @@ -1,6 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; + +import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import {UpdateMethodSelection} from '../UpdateMethodSelection'; import {EventScheduleSummary} from '../../Events'; @@ -111,10 +113,10 @@ AssignCalendarComponent.propTypes = { const mapDispatchToProps = (dispatch) => ({ onSubmit: (original, updates) => ( dispatch(actions.main.save(original, updates, false)) - .then((savedItem) => dispatch(actions.events.api.unlock(savedItem))) + .then((savedItem) => planningApi.locks.unlockItem(savedItem)) ), onHide: (event) => { - dispatch(actions.events.api.unlock(event)); + planningApi.locks.unlockItem(event); }, }); diff --git a/client/components/ItemActionConfirmation/forms/cancelEventForm.tsx b/client/components/ItemActionConfirmation/forms/cancelEventForm.tsx index f99e5b14f..a13076a71 100644 --- a/client/components/ItemActionConfirmation/forms/cancelEventForm.tsx +++ b/client/components/ItemActionConfirmation/forms/cancelEventForm.tsx @@ -3,10 +3,12 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {get, cloneDeep, isEmpty} from 'lodash'; +import {IEventItem} from '../../../interfaces'; import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; import {eventUtils, gettext} from '../../../utils'; import {EVENTS} from '../../../constants'; +import {onItemActionModalHide} from './utils'; import {EventScheduleSummary} from '../../Events'; import {UpdateMethodSelection} from '../UpdateMethodSelection'; @@ -197,17 +199,11 @@ const mapDispatchToProps = (dispatch) => ({ onSubmit: (original, updates) => dispatch( actions.events.ui.cancelEvent(original, updates) ), - onHide: (original, modalProps) => { - const promise = original.lock_action === EVENTS.ITEM_ACTIONS.CANCEL_EVENT.lock_action ? - dispatch(actions.events.api.unlock(original)) : - Promise.resolve(original); - - if (get(modalProps, 'onCloseModal')) { - promise.then((updatedEvent) => modalProps.onCloseModal(updatedEvent)); - } - - return promise; - }, + onHide: (original: IEventItem, modalProps) => onItemActionModalHide( + original, + original.lock_action === EVENTS.ITEM_ACTIONS.CANCEL_EVENT.lock_action, + modalProps + ), }); export const CancelEventForm = connect( diff --git a/client/components/ItemActionConfirmation/forms/cancelPlanningCoveragesForm.tsx b/client/components/ItemActionConfirmation/forms/cancelPlanningCoveragesForm.tsx index 0227bce69..24608dc29 100644 --- a/client/components/ItemActionConfirmation/forms/cancelPlanningCoveragesForm.tsx +++ b/client/components/ItemActionConfirmation/forms/cancelPlanningCoveragesForm.tsx @@ -3,12 +3,14 @@ import {connect} from 'react-redux'; import {get, cloneDeep, isEmpty} from 'lodash'; import {IPlanningItem, IPlanningProfile} from '../../../interfaces'; +import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; import {formProfile} from '../../../validators'; import {isItemCancelled, gettext} from '../../../utils'; import {PLANNING} from '../../../constants'; +import {onItemActionModalHide} from './utils'; import {Row} from '../../UI/Preview'; import {TextAreaInput} from '../../UI/Form'; @@ -147,18 +149,17 @@ const mapDispatchToProps = (dispatch) => ({ return dispatch(cancelDispatch(original, updates)) .then((updatedPlan: IPlanningItem) => { if (cancelBasedLocks.includes(original.lock_action) || isItemCancelled(updatedPlan)) { - return dispatch(actions.planning.api.unlock(updatedPlan)); + return planningApi.locks.unlockItem(updatedPlan); } return Promise.resolve(updatedPlan); }); }, - - onHide: (planning: IPlanningItem) => { - if (cancelBasedLocks.includes(planning.lock_action)) { - dispatch(actions.planning.api.unlock(planning)); - } - }, + onHide: (original: IPlanningItem, modalProps) => onItemActionModalHide( + original, + cancelBasedLocks.includes(original.lock_action), + modalProps, + ), }); export const CancelPlanningCoveragesForm = connect( diff --git a/client/components/ItemActionConfirmation/forms/convertToRecurringEventForm.tsx b/client/components/ItemActionConfirmation/forms/convertToRecurringEventForm.tsx index e2240608c..5c21ae0c4 100644 --- a/client/components/ItemActionConfirmation/forms/convertToRecurringEventForm.tsx +++ b/client/components/ItemActionConfirmation/forms/convertToRecurringEventForm.tsx @@ -4,6 +4,7 @@ import {connect} from 'react-redux'; import {get, isEqual, cloneDeep} from 'lodash'; import {appConfig} from 'appConfig'; +import {IEventItem} from '../../../interfaces'; import * as actions from '../../../actions'; import {EventScheduleSummary, EventScheduleInput} from '../../Events'; @@ -13,6 +14,7 @@ import {Row} from '../../UI/Preview'; import {Field} from '../../UI/Form'; import {validateItem} from '../../../validators'; import {updateFormValues, eventUtils, timeUtils, gettext} from '../../../utils'; +import {onItemActionModalHide} from './utils'; import '../style.scss'; @@ -207,11 +209,11 @@ const mapDispatchToProps = (dispatch) => ({ return dispatch(actions.main.save(original, newUpdates, false)); }, - onHide: (event) => { - if (event.lock_action === EVENTS.ITEM_ACTIONS.CONVERT_TO_RECURRING.lock_action) { - dispatch(actions.events.api.unlock(event)); - } - }, + onHide: (original: IEventItem, modalProps) => onItemActionModalHide( + original, + original.lock_action === EVENTS.ITEM_ACTIONS.CONVERT_TO_RECURRING.lock_action, + modalProps, + ), onValidate: (item, profile, errors, errorsMessages, fieldsToValidate) => dispatch(validateItem({ profileName: ITEM_TYPE.EVENT, diff --git a/client/components/ItemActionConfirmation/forms/editPriorityForm.tsx b/client/components/ItemActionConfirmation/forms/editPriorityForm.tsx index 024b16805..d9d07477b 100644 --- a/client/components/ItemActionConfirmation/forms/editPriorityForm.tsx +++ b/client/components/ItemActionConfirmation/forms/editPriorityForm.tsx @@ -3,12 +3,15 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {isEqual, get} from 'lodash'; +import {IAssignmentItem} from '../../../interfaces'; import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; import {ASSIGNMENTS} from '../../../constants'; import {gettext, getItemInArrayById} from '../../../utils'; import {getUserInterfaceLanguageFromCV} from '../../../utils/users'; +import {onItemActionModalHide} from './utils'; + import {Row, TextInput, ColouredValueInput} from '../../UI/Form'; import {AbsoluteDate} from '../..'; @@ -166,11 +169,11 @@ const mapDispatchToProps = (dispatch) => ({ payload: {assignment: updatedAssignment}, })), - onHide: (assignment) => { - if (assignment.lock_action === 'edit_priority') { - dispatch(actions.assignments.api.unlock(assignment)); - } - }, + onHide: (original: IAssignmentItem, modalProps) => onItemActionModalHide( + original, + original.lock_action === ASSIGNMENTS.ITEM_ACTIONS.EDIT_PRIORITY.lock_action, + modalProps, + ), }); export const EditPriorityForm = connect( diff --git a/client/components/ItemActionConfirmation/forms/postponeEventForm.tsx b/client/components/ItemActionConfirmation/forms/postponeEventForm.tsx index 5be819f54..f01e4a6a0 100644 --- a/client/components/ItemActionConfirmation/forms/postponeEventForm.tsx +++ b/client/components/ItemActionConfirmation/forms/postponeEventForm.tsx @@ -3,10 +3,13 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {get, cloneDeep, isEmpty} from 'lodash'; +import {IEventItem} from '../../../interfaces'; + import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; import {gettext} from '../../../utils'; import {EVENTS} from '../../../constants'; +import {onItemActionModalHide} from './utils'; import {EventScheduleSummary} from '../../Events'; import {Row} from '../../UI/Preview'; @@ -174,17 +177,11 @@ const mapDispatchToProps = (dispatch) => ({ return promise; }, - onHide: (event, modalProps) => { - const promise = event.lock_action === EVENTS.ITEM_ACTIONS.POSTPONE_EVENT.lock_action ? - dispatch(actions.events.api.unlock(event)) : - Promise.resolve(event); - - if (get(modalProps, 'onCloseModal')) { - promise.then((updatedEvent) => modalProps.onCloseModal(updatedEvent)); - } - - return promise; - }, + onHide: (original: IEventItem, modalProps) => onItemActionModalHide( + original, + original.lock_action === EVENTS.ITEM_ACTIONS.POSTPONE_EVENT.lock_action, + modalProps, + ), }); export const PostponeEventForm = connect( diff --git a/client/components/ItemActionConfirmation/forms/rescheduleEventForm.tsx b/client/components/ItemActionConfirmation/forms/rescheduleEventForm.tsx index cf5795ed4..de8adf08d 100644 --- a/client/components/ItemActionConfirmation/forms/rescheduleEventForm.tsx +++ b/client/components/ItemActionConfirmation/forms/rescheduleEventForm.tsx @@ -5,12 +5,14 @@ import {get, isEqual, cloneDeep, omit, isEmpty} from 'lodash'; import moment from 'moment'; import {appConfig} from 'appConfig'; +import {IEventItem} from '../../../interfaces'; import * as actions from '../../../actions'; import {formProfile, validateItem} from '../../../validators'; import * as selectors from '../../../selectors'; import {gettext, eventUtils, getDateTimeString, updateFormValues, timeUtils} from '../../../utils'; import {EVENTS, ITEM_TYPE, TIME_COMPARISON_GRANULARITY, TO_BE_CONFIRMED_FIELD} from '../../../constants'; +import {onItemActionModalHide} from './utils'; import {EventScheduleSummary, EventScheduleInput} from '../../Events'; import {RelatedPlannings} from '../../'; @@ -354,17 +356,11 @@ const mapDispatchToProps = (dispatch) => ({ return promise; }, - onHide: (event, modalProps) => { - const promise = event.lock_action === EVENTS.ITEM_ACTIONS.RESCHEDULE_EVENT.lock_action ? - dispatch(actions.events.api.unlock(event)) : - Promise.resolve(event); - - if (get(modalProps, 'onCloseModal')) { - promise.then((updatedEvent) => modalProps.onCloseModal(updatedEvent)); - } - - return promise; - }, + onHide: (original: IEventItem, modalProps) => onItemActionModalHide( + original, + original.lock_action === EVENTS.ITEM_ACTIONS.RESCHEDULE_EVENT.lock_action, + modalProps, + ), onValidate: (item, profile, errors, errorMessages, fieldsToValidate) => dispatch(validateItem({ profileName: ITEM_TYPE.EVENT, diff --git a/client/components/ItemActionConfirmation/forms/spikeEventForm.tsx b/client/components/ItemActionConfirmation/forms/spikeEventForm.tsx index d701e4c6b..3a124d66e 100644 --- a/client/components/ItemActionConfirmation/forms/spikeEventForm.tsx +++ b/client/components/ItemActionConfirmation/forms/spikeEventForm.tsx @@ -3,6 +3,8 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {get} from 'lodash'; +import {IEventItem} from '../../../interfaces'; + import * as actions from '../../../actions'; import '../style.scss'; import {UpdateMethodSelection} from '../UpdateMethodSelection'; @@ -10,6 +12,8 @@ import {RelatedEvents} from '../../index'; import {EVENTS} from '../../../constants'; import {EventScheduleSummary} from '../../Events'; import {eventUtils, gettext} from '../../../utils'; +import {onItemActionModalHide} from './utils'; + import {Row} from '../../UI/Preview'; export class SpikeEventComponent extends React.Component { @@ -123,12 +127,11 @@ SpikeEventComponent.propTypes = { const mapDispatchToProps = (dispatch) => ({ onSubmit: (event) => dispatch(actions.events.ui.spike(event)), - onHide: (event, modalProps) => { - if (get(modalProps, 'onCloseModal')) { - modalProps.onCloseModal(event); - } - return Promise.resolve(event); - }, + onHide: (original: IEventItem, modalProps) => onItemActionModalHide( + original, + original.lock_action === EVENTS.ITEM_ACTIONS.SPIKE.lock_action, + modalProps, + ), }); export const SpikeEventForm = connect( diff --git a/client/components/ItemActionConfirmation/forms/spikePlanningForm.tsx b/client/components/ItemActionConfirmation/forms/spikePlanningForm.tsx index 57d13a806..013a73ab1 100644 --- a/client/components/ItemActionConfirmation/forms/spikePlanningForm.tsx +++ b/client/components/ItemActionConfirmation/forms/spikePlanningForm.tsx @@ -1,9 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; -import {get} from 'lodash'; + +import {IPlanningItem} from '../../../interfaces'; import {appConfig} from 'appConfig'; +import {PLANNING} from '../../../constants'; +import {onItemActionModalHide} from './utils'; import * as actions from '../../../actions'; import '../style.scss'; @@ -64,12 +67,11 @@ SpikePlanningComponent.propTypes = { const mapDispatchToProps = (dispatch) => ({ onSubmit: (plan) => dispatch(actions.planning.ui.spike(plan)), - onHide: (plan, modalProps) => { - if (get(modalProps, 'onCloseModal')) { - modalProps.onCloseModal(plan); - } - return Promise.resolve(plan); - }, + onHide: (original: IPlanningItem, modalProps) => onItemActionModalHide( + original, + original.lock_action === PLANNING.ITEM_ACTIONS.SPIKE.lock_action, + modalProps, + ), }); export const SpikePlanningForm = connect( diff --git a/client/components/ItemActionConfirmation/forms/unspikeEventForm.tsx b/client/components/ItemActionConfirmation/forms/unspikeEventForm.tsx index 8efd26299..cad11bf54 100644 --- a/client/components/ItemActionConfirmation/forms/unspikeEventForm.tsx +++ b/client/components/ItemActionConfirmation/forms/unspikeEventForm.tsx @@ -3,12 +3,16 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {get} from 'lodash'; +import {IEventItem} from '../../../interfaces'; + import * as actions from '../../../actions'; import '../style.scss'; import {WORKFLOW_STATE, EVENTS} from '../../../constants'; import {UpdateMethodSelection} from '../UpdateMethodSelection'; import {EventScheduleSummary} from '../../Events'; import {eventUtils, gettext} from '../../../utils'; +import {onItemActionModalHide} from './utils'; + import {Row} from '../../UI/Preview'; export class UnspikeEventComponent extends React.Component { @@ -111,6 +115,11 @@ UnspikeEventComponent.propTypes = { const mapDispatchToProps = (dispatch) => ({ onSubmit: (event) => (dispatch(actions.events.ui.unspike(event))), + onHide: (original: IEventItem, modalProps) => onItemActionModalHide( + original, + original.lock_action === EVENTS.ITEM_ACTIONS.UNSPIKE.lock_action, + modalProps, + ), }); export const UnspikeEventForm = connect( diff --git a/client/components/ItemActionConfirmation/forms/unspikePlanningForm.tsx b/client/components/ItemActionConfirmation/forms/unspikePlanningForm.tsx index caa1c29cf..1069578cb 100644 --- a/client/components/ItemActionConfirmation/forms/unspikePlanningForm.tsx +++ b/client/components/ItemActionConfirmation/forms/unspikePlanningForm.tsx @@ -3,10 +3,14 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {appConfig} from 'appConfig'; +import {IPlanningItem} from '../../../interfaces'; +import {PLANNING} from '../../../constants'; import * as actions from '../../../actions'; import '../style.scss'; import {gettext, getDateTimeString} from '../../../utils'; +import {onItemActionModalHide} from './utils'; + import {Row} from '../../UI/Preview'; export class UnspikePlanningComponent extends React.Component { @@ -62,6 +66,11 @@ UnspikePlanningComponent.propTypes = { const mapDispatchToProps = (dispatch) => ({ onSubmit: (plan) => dispatch(actions.planning.ui.unspike(plan)), + onHide: (original: IPlanningItem, modalProps) => onItemActionModalHide( + original, + original.lock_action === PLANNING.ITEM_ACTIONS.UNSPIKE.lock_action, + modalProps, + ), }); export const UnspikePlanningForm = connect( diff --git a/client/components/ItemActionConfirmation/forms/updateAssignmentForm.tsx b/client/components/ItemActionConfirmation/forms/updateAssignmentForm.tsx index 2a7f2a9ac..2046266c7 100644 --- a/client/components/ItemActionConfirmation/forms/updateAssignmentForm.tsx +++ b/client/components/ItemActionConfirmation/forms/updateAssignmentForm.tsx @@ -3,12 +3,15 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {get, set, isEqual, cloneDeep} from 'lodash'; +import {IAssignmentItem} from '../../../interfaces'; + import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; import {ASSIGNMENTS} from '../../../constants'; import {gettext, getItemInArrayById, assignmentUtils} from '../../../utils'; import {getUserInterfaceLanguageFromCV} from '../../../utils/users'; +import {onItemActionModalHide} from './utils'; import {AssignmentEditor} from '../../Assignments'; @@ -179,11 +182,11 @@ const mapDispatchToProps = (dispatch) => ({ payload: {assignment: updatedAssignment}, })), - onHide: (assignment) => { - if (assignment.lock_action === 'reassign') { - dispatch(actions.assignments.api.unlock(assignment)); - } - }, + onHide: (original: IAssignmentItem, modalProps) => onItemActionModalHide( + original, + original.lock_action === ASSIGNMENTS.ITEM_ACTIONS.REASSIGN.lock_action, + modalProps, + ), }); export const UpdateAssignmentForm = connect( diff --git a/client/components/ItemActionConfirmation/forms/updateEventRepetitionsForm.tsx b/client/components/ItemActionConfirmation/forms/updateEventRepetitionsForm.tsx index 00195a8ec..361108ace 100644 --- a/client/components/ItemActionConfirmation/forms/updateEventRepetitionsForm.tsx +++ b/client/components/ItemActionConfirmation/forms/updateEventRepetitionsForm.tsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {appConfig} from 'appConfig'; +import {IEventItem} from '../../../interfaces'; import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; @@ -10,6 +11,8 @@ import {gettext, updateFormValues, eventUtils, timeUtils} from '../../../utils'; import {Row} from '../../UI/Preview/'; import {RepeatEventSummary} from '../../Events'; import {RecurringRulesInput} from '../../Events/RecurringRulesInput/index'; +import {onItemActionModalHide} from './utils'; + import '../style.scss'; import {get, cloneDeep, isEqual, set} from 'lodash'; import {EVENTS, ITEM_TYPE, TIME_COMPARISON_GRANULARITY} from '../../../constants'; @@ -169,17 +172,11 @@ const mapDispatchToProps = (dispatch) => ({ return promise; }, - onHide: (event, modalProps) => { - const promise = event.lock_action === EVENTS.ITEM_ACTIONS.UPDATE_REPETITIONS.lock_action ? - dispatch(actions.events.api.unlock(event)) : - Promise.resolve(event); - - if (get(modalProps, 'onCloseModal')) { - promise.then((updatedEvent) => modalProps.onCloseModal(updatedEvent)); - } - - return promise; - }, + onHide: (original: IEventItem, modalProps) => onItemActionModalHide( + original, + original.lock_action === EVENTS.ITEM_ACTIONS.UPDATE_REPETITIONS.lock_action, + modalProps, + ), onValidate: (item, profile, errors, errorMessages) => dispatch(validateItem({ profileName: ITEM_TYPE.EVENT, diff: item, diff --git a/client/components/ItemActionConfirmation/forms/updateRecurringEventsForm.tsx b/client/components/ItemActionConfirmation/forms/updateRecurringEventsForm.tsx index 0a54493ed..498a2e395 100644 --- a/client/components/ItemActionConfirmation/forms/updateRecurringEventsForm.tsx +++ b/client/components/ItemActionConfirmation/forms/updateRecurringEventsForm.tsx @@ -1,12 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; + +import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import {get} from 'lodash'; import {UpdateMethodSelection} from '../UpdateMethodSelection'; import {EVENTS} from '../../../constants'; import {EventScheduleSummary} from '../../Events'; import {eventUtils, gettext} from '../../../utils'; +import {onItemActionModalHide} from './utils'; + import {Row} from '../../UI/Preview'; import '../style.scss'; @@ -136,7 +140,7 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ dispatch(actions.main.save(original, updates, false)) .then((savedItem) => { if (ownProps.modalProps.unlockOnClose) { - dispatch(actions.events.api.unlock(savedItem)); + planningApi.locks.unlockItem(savedItem); } if (ownProps.resolve) { @@ -144,15 +148,13 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ } }) ), - onHide: (event) => { - if (ownProps.modalProps.unlockOnClose) { - dispatch(actions.events.api.unlock(event)); - } - - if (ownProps.resolve) { - ownProps.resolve(); - } - }, + onHide: (original) => ( + onItemActionModalHide(original, ownProps?.modalProps?.unlockOnClose, ownProps?.modalProps).then(() => { + if (ownProps?.resolve != null) { + ownProps.resolve(); + } + }) + ), }); export const UpdateRecurringEventsForm = connect( diff --git a/client/components/ItemActionConfirmation/forms/updateTimeForm.tsx b/client/components/ItemActionConfirmation/forms/updateTimeForm.tsx index bf6873b33..8974255f6 100644 --- a/client/components/ItemActionConfirmation/forms/updateTimeForm.tsx +++ b/client/components/ItemActionConfirmation/forms/updateTimeForm.tsx @@ -6,10 +6,13 @@ import moment from 'moment'; import {get, set, cloneDeep, isEqual} from 'lodash'; import {appConfig} from 'appConfig'; +import {IEventItem} from '../../../interfaces'; import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; import {eventUtils, gettext, timeUtils} from '../../../utils'; +import {onItemActionModalHide} from './utils'; + import {Label, TimeInput, Row as FormRow, LineInput, Field} from '../../UI/Form/'; import {Row} from '../../UI/Preview/'; import {EventScheduleSummary} from '../../Events'; @@ -329,17 +332,11 @@ const mapDispatchToProps = (dispatch) => ({ return promise; }, - onHide: (event, modalProps) => { - const promise = event.lock_action === EVENTS.ITEM_ACTIONS.UPDATE_TIME.lock_action ? - dispatch(actions.events.api.unlock(event)) : - Promise.resolve(event); - - if (get(modalProps, 'onCloseModal')) { - promise.then((updatedEvent) => modalProps.onCloseModal(updatedEvent)); - } - - return promise; - }, + onHide: (original: IEventItem, modalProps) => onItemActionModalHide( + original, + original.lock_action === EVENTS.ITEM_ACTIONS.UPDATE_TIME.lock_action, + modalProps, + ), onValidate: (item, profile, errors, errorMessages, fieldsToValidate) => dispatch(validateItem({ profileName: ITEM_TYPE.EVENT, diff: item, diff --git a/client/components/ItemActionConfirmation/forms/utils.ts b/client/components/ItemActionConfirmation/forms/utils.ts new file mode 100644 index 000000000..e04cad702 --- /dev/null +++ b/client/components/ItemActionConfirmation/forms/utils.ts @@ -0,0 +1,22 @@ +import {IAssignmentOrPlanningItem} from '../../../interfaces'; +import {planningApi} from '../../../superdeskApi'; + +interface IModalProps { + onCloseModal?(item: IAssignmentOrPlanningItem): void; +} + +export function onItemActionModalHide( + original: IAssignmentOrPlanningItem, + unlockItem: boolean, + modalProps:IModalProps +) { + return ( + unlockItem ? + planningApi.locks.unlockItem(original) : + Promise.resolve(original) + ).then((updatedItem) => { + if (modalProps?.onCloseModal != null) { + modalProps.onCloseModal(updatedItem); + } + }); +} diff --git a/client/components/LockContainer/LockContainerPopup.tsx b/client/components/LockContainer/LockContainerPopup.tsx index 7f662b497..cf51db53b 100644 --- a/client/components/LockContainer/LockContainerPopup.tsx +++ b/client/components/LockContainer/LockContainerPopup.tsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import {gettext} from '../../utils'; -import {UserAvatar} from '../'; +import {UserAvatarWithMargin} from '../../components/UserAvatar'; import {Popup, Header, Content, Footer} from '../UI/Popup'; import {Button} from '../UI'; @@ -33,7 +33,7 @@ export const LockContainerPopup = ({ noBorder={true} /> - +
{user.display_name}
diff --git a/client/components/LockContainer/index.tsx b/client/components/LockContainer/index.tsx index aa9a58676..21c96c3a0 100644 --- a/client/components/LockContainer/index.tsx +++ b/client/components/LockContainer/index.tsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import {get} from 'lodash'; import classNames from 'classnames'; -import {UserAvatar} from '../'; +import {UserAvatarWithMargin} from '../../components/UserAvatar'; import {LockContainerPopup} from './LockContainerPopup'; import './style.scss'; @@ -49,11 +49,7 @@ export class LockContainer extends React.Component { )} > - +
{this.state.openUnlockPopup && ( diff --git a/client/components/Main/ItemEditor/Editor.tsx b/client/components/Main/ItemEditor/Editor.tsx index 3103b1eff..3d5997db0 100644 --- a/client/components/Main/ItemEditor/Editor.tsx +++ b/client/components/Main/ItemEditor/Editor.tsx @@ -122,7 +122,7 @@ export class EditorComponent extends React.Component updateFormValues(diff, field, value); } - if (typeof field === 'object') { + if (field && typeof field === 'object') { Object.keys(field).forEach((subField) => { this.editorApi.events.beforeFormUpdates(newState, subField, diff[subField]); }); diff --git a/client/components/Main/ItemEditor/EditorHeader.tsx b/client/components/Main/ItemEditor/EditorHeader.tsx index dcc0b45ee..2874078aa 100644 --- a/client/components/Main/ItemEditor/EditorHeader.tsx +++ b/client/components/Main/ItemEditor/EditorHeader.tsx @@ -201,7 +201,7 @@ export class EditorHeader extends React.Component { states.canEditExpired = privileges[PRIVILEGES.EDIT_EXPIRED]; states.itemLock = lockUtils.getLock(initialValues, lockedItems); states.isLockedInContext = addNewsItemToPlanning ? - planningUtils.isLockedForAddToPlanning(initialValues) : + lockUtils.isLockedForAddToPlanning(initialValues, lockedItems) : !!states.itemLock; states.lockedUser = lockUtils.getLockedUser(initialValues, lockedItems, users); diff --git a/client/components/Main/ItemEditor/EditorItemActions.tsx b/client/components/Main/ItemEditor/EditorItemActions.tsx index b36c38ece..47df77cf3 100644 --- a/client/components/Main/ItemEditor/EditorItemActions.tsx +++ b/client/components/Main/ItemEditor/EditorItemActions.tsx @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; +import {superdeskApi} from '../../../superdeskApi'; import {ITEM_TYPE, EVENTS, PLANNING} from '../../../constants'; import {getItemType, eventUtils, planningUtils} from '../../../utils'; import eventsApi from '../../../actions/events/api'; @@ -10,6 +11,7 @@ import * as allActions from '../../../actions'; import {ItemActionsMenu} from '../../index'; const EditorItemActionsComponent = (props) => { + const {gettext} = superdeskApi.localization; const { item, event, diff --git a/client/components/Main/ItemEditor/ItemManager.ts b/client/components/Main/ItemEditor/ItemManager.ts index a8a74cce3..195dc6e83 100644 --- a/client/components/Main/ItemEditor/ItemManager.ts +++ b/client/components/Main/ItemEditor/ItemManager.ts @@ -52,7 +52,7 @@ export class ItemManager { this.save = this.save.bind(this); this.saveAndPost = this.saveAndPost.bind(this); this.saveAndUnpost = this.saveAndUnpost.bind(this); - this.lock = this.lock.bind(this); + // this.lock = this.lock.bind(this); this.unlockThenLock = this.unlockThenLock.bind(this); this.changeAction = this.changeAction.bind(this); this.addCoverage = this.addCoverage.bind(this); @@ -358,7 +358,8 @@ export class ItemManager { ) .then((original) => { initialValues = cloneDeep(original); - return this.dispatch(actions.locks.lock(original)); + + return planningApi.locks.lockItem(original); }); } else { // Fetch the latest item from the API to view in read-only mode @@ -704,11 +705,8 @@ export class ItemManager { this.autoSave.remove(); // If event was created by a planning item, unlock the planning item - if (get(updates, '_planning_item')) { - this.dispatch(actions.planning.api.unlock({ - _id: updates._planning_item, - type: ITEM_TYPE.PLANNING, - })); + if (updates.type === 'event' && updates._planning_item != null) { + planningApi.locks.unlockItemById(updates._planning_item, 'planning'); } if (closeAfter) { @@ -810,37 +808,24 @@ export class ItemManager { return this.setState({initialValues}).then(() => this.editor.onChangeHandler(diff, null, false)); } - lock(item: IEventOrPlanningItem) { - return this.dispatch( - actions.locks.lock(item) - ); - } - - unlock() { - const {itemId, itemType} = this.props; - let action = actions.locks.unlock; + // TODO: Is this used anywhere + // lock(item: IEventOrPlanningItem) { + // return planningApi.locks.lockItem(item); + // } - if (!itemId || isTemporaryId(itemId)) { - return Promise.resolve(); - } else if (itemType === ITEM_TYPE.EVENT) { - action = actions.events.api.unlock; - } else if (itemType === ITEM_TYPE.PLANNING) { - action = actions.planning.api.unlock; - } - - return this.dispatch(action({ - _id: itemId, - type: itemType, - })); - } + // TODO: Is this used anywhere + // unlock() { + // return planningApi.locks.unlockItem(this.props.item); + // } unlockThenLock(item: IEventOrPlanningItem) { return this.setState({ itemReady: false, loading: true, }) - .then(() => this.dispatch( - actions.locks.unlockThenLock(item, this.props.inModalView) + .then(() => (planningApi.locks.unlockThenLockItem(item, 'edit'))) + .then((lockedItem) => ( + this.dispatch(actions.main.openForEdit(lockedItem, true, this.props.inModalView)) )); } @@ -850,15 +835,13 @@ export class ItemManager { let promises = []; if (shouldUnLockItem(initialValues, session, currentWorkspace, this.props.lockedItems)) { - promises.push(this.unlock()); + promises.push(planningApi.locks.unlockItem(this.props.item)); + // promises.push(this.unlock()); } // If event was created by a planning item, unlock the planning item if (diff?.type === 'event' && diff._planning_item) { - this.dispatch(actions.planning.api.unlock({ - _id: diff._planning_item, - type: ITEM_TYPE.PLANNING, - })); + planningApi.locks.unlockItemById(diff._planning_item, 'planning'); } promises.push(this.autoSave.remove()); @@ -898,7 +881,7 @@ export class ItemManager { } addCoverageToWorkflow(planning, coverage, index) { - return this.dispatch(actions.planning.ui.addCoverageToWorkflow(planning, coverage, index)) + return planningApi.planning.coverages.addCoverageToWorkflow(planning, coverage, index) .then((updates) => this.finalisePartialSave(this.getCoverageAfterPartialSave(updates, index))); } diff --git a/client/components/Main/ItemEditor/tests/ItemManager_test.ts b/client/components/Main/ItemEditor/tests/ItemManager_test.ts index ed3c8ae45..2b83eba8b 100644 --- a/client/components/Main/ItemEditor/tests/ItemManager_test.ts +++ b/client/components/Main/ItemEditor/tests/ItemManager_test.ts @@ -4,9 +4,8 @@ import moment from 'moment-timezone'; import {appConfig} from 'appConfig'; -import {main, locks} from '../../../../actions'; -import eventsApi from '../../../../actions/events/api'; -import planningApi from '../../../../actions/planning/api'; +import {planningApi} from '../../../../superdeskApi'; +import {main} from '../../../../actions'; import planningUi from '../../../../actions/planning/ui'; import {EVENTS} from '../../../../constants'; @@ -96,6 +95,7 @@ describe('components.Main.ItemManager', () => { defaultCalendar: [], defaultPlace: [], saveDiffToStore: sinon.spy(), + lockedItems: testData.locks, }, state: {}, setState: sinon.spy((newState, cb) => { @@ -426,7 +426,7 @@ describe('components.Main.ItemManager', () => { sinon.spy(manager, 'loadItem'); sinon.spy(manager, 'loadReadOnlyItem'); - sinon.stub(locks, 'lock').callsFake( + sinon.stub(planningApi.locks, 'lockItem').callsFake( (original) => Promise.resolve(original) ); sinon.stub(main, 'fetchById').callsFake((itemId, itemType) => ( @@ -446,7 +446,7 @@ describe('components.Main.ItemManager', () => { restoreSinonStub(manager.loadItem); restoreSinonStub(manager.loadReadOnlyItem); restoreSinonStub(main.fetchById); - restoreSinonStub(locks.lock); + restoreSinonStub(planningApi.locks.lockItem); }); it('createNew Event', (done) => { @@ -553,8 +553,8 @@ describe('components.Main.ItemManager', () => { expect(main.fetchById.callCount).toBe(1); expect(main.fetchById.args[0]).toEqual(['e1', 'event', true]); - expect(locks.lock.callCount).toBe(1); - expect(locks.lock.args[0]).toEqual([testData.events[0]]); + expect(planningApi.locks.lockItem.callCount).toBe(1); + expect(planningApi.locks.lockItem.args[0]).toEqual([testData.events[0]]); expect(editor.autoSave.createOrLoadAutosave.callCount).toBe(1); expect(editor.autoSave.createOrLoadAutosave.args[0]).toEqual([ @@ -601,7 +601,7 @@ describe('components.Main.ItemManager', () => { true, ]); - expect(locks.lock.callCount).toBe(0); + expect(planningApi.locks.lockItem.callCount).toBe(0); expectState({ initialValues: testData.events[0], @@ -761,14 +761,14 @@ describe('components.Main.ItemManager', () => { editor.setState(states.loading); sinon.stub(main, 'fetchById').returns(Promise.resolve(testData.events[0])); - sinon.stub(locks, 'lock').returns(Promise.resolve(lockedItem)); + sinon.stub(planningApi.locks, 'lockItem').returns(Promise.resolve(lockedItem)); sinon.stub(manager, 'addCoverage'); sinon.stub(manager, 'changeAction'); }); afterEach(() => { restoreSinonStub(main.fetchById); - restoreSinonStub(locks.lock); + restoreSinonStub(planningApi.locks.lockItem); restoreSinonStub(manager.addCoverage); restoreSinonStub(manager.changeAction); }); @@ -785,7 +785,7 @@ describe('components.Main.ItemManager', () => { manager.loadItem(editor.props) .then(() => { expect(main.fetchById.callCount).toBe(0); - expect(locks.lock.callCount).toBe(0); + expect(planningApi.locks.lockItem.callCount).toBe(0); expect(editor.autoSave.createOrLoadAutosave.callCount).toBe(1); expect(editor.autoSave.createOrLoadAutosave.args[0]).toEqual([ @@ -824,8 +824,8 @@ describe('components.Main.ItemManager', () => { expect(main.fetchById.callCount).toBe(1); expect(main.fetchById.args[0]).toEqual(['e1', 'event', true]); - expect(locks.lock.callCount).toBe(1); - expect(locks.lock.args[0]).toEqual([testData.events[0]]); + expect(planningApi.locks.lockItem.callCount).toBe(1); + expect(planningApi.locks.lockItem.args[0]).toEqual([testData.events[0]]); expect(editor.autoSave.createOrLoadAutosave.callCount).toBe(1); expect(editor.autoSave.createOrLoadAutosave.args[0]).toEqual([ @@ -835,7 +835,10 @@ describe('components.Main.ItemManager', () => { expectState({ initialValues: lockedItem, - diff: lockedItem, + diff: { + ...lockedItem, + associated_plannings: [testData.plannings[1]], + }, dirty: false, submitting: false, itemReady: true, @@ -860,11 +863,14 @@ describe('components.Main.ItemManager', () => { .then(() => { expect(main.fetchById.callCount).toBe(1); expect(main.fetchById.args[0]).toEqual(['e1', 'event', true]); - expect(locks.lock.callCount).toBe(0); + expect(planningApi.locks.lockItem.callCount).toBe(0); expectState({ initialValues: testData.events[0], - diff: testData.events[0], + diff: { + ...testData.events[0], + associated_plannings: [testData.plannings[1]], + }, dirty: false, submitting: false, itemReady: true, @@ -911,8 +917,8 @@ describe('components.Main.ItemManager', () => { }); it('changes the editor to read-only on failure', (done) => { - restoreSinonStub(locks.lock); - sinon.stub(locks, 'lock').returns(Promise.reject()); + restoreSinonStub(planningApi.locks.lockItem); + sinon.stub(planningApi.locks, 'lockItem').returns(Promise.reject()); const nextProps = { itemId: 'e1', @@ -1037,7 +1043,8 @@ describe('components.Main.ItemManager', () => { sinon.stub(manager, 'changeAction'); sinon.stub(manager, 'unlockAndCancel'); sinon.stub(manager, '_saveFromAuthoring'); - sinon.stub(planningApi, 'unlock'); + sinon.stub(planningApi.locks, 'unlockItem'); + sinon.stub(planningApi.locks, 'unlockItemById'); }); afterEach(() => { @@ -1045,7 +1052,8 @@ describe('components.Main.ItemManager', () => { restoreSinonStub(manager.changeAction); restoreSinonStub(manager.unlockAndCancel); restoreSinonStub(manager._saveFromAuthoring); - restoreSinonStub(planningApi.unlock); + restoreSinonStub(planningApi.locks.unlockItem); + restoreSinonStub(planningApi.locks.unlockItemById); }); it('returns without saving if there are validation errors', (done) => { @@ -1105,7 +1113,7 @@ describe('components.Main.ItemManager', () => { ]); expect(editor.autoSave.remove.callCount).toBe(1); - expect(planningApi.unlock.callCount).toBe(0); + expect(planningApi.locks.unlockItem.callCount).toBe(0); expect(manager.changeAction.callCount).toBe(1); expect(manager.changeAction.args[0]).toEqual([ 'edit', @@ -1134,11 +1142,8 @@ describe('components.Main.ItemManager', () => { manager._save() .then(() => { - expect(planningApi.unlock.callCount).toBe(1); - expect(planningApi.unlock.args[0]).toEqual([{ - _id: 'p1', - type: 'planning', - }]); + expect(planningApi.locks.unlockItemById.callCount).toBe(1); + expect(planningApi.locks.unlockItemById.args[0]).toEqual(['p1', 'planning']); done(); }) @@ -1195,14 +1200,20 @@ describe('components.Main.ItemManager', () => { expectState({ initialValues: item, - diff: item, + diff: { + ...item, + associated_plannings: [testData.plannings[1]], + }, dirty: false, submitFailed: false, ...states.notLoading, }); expect(editor.autoSave.saveAutosave.callCount).toBe(1); - expect(editor.autoSave.saveAutosave.args[0][1]).toEqual(item); + expect(editor.autoSave.saveAutosave.args[0][1]).toEqual({ + ...item, + associated_plannings: [testData.plannings[1]], + }); expect(editor.autoSave.flushAutosave.callCount).toBe(2); done(); @@ -1502,119 +1513,13 @@ describe('components.Main.ItemManager', () => { .catch(done.fail); }); - describe('lock', () => { - afterEach(() => { - restoreSinonStub(locks.lock); - }); - - it('lock calls locks.lock', () => { - sinon.stub(locks, 'lock'); - manager.lock(testData.events[0]); - expect(locks.lock.callCount).toBe(1); - expect(locks.lock.args[0]).toEqual([testData.events[0]]); - }); - }); - - describe('unlock', () => { - beforeEach(() => { - sinon.stub(locks, 'unlock'); - sinon.stub(locks, 'unlockThenLock'); - sinon.stub(eventsApi, 'unlock'); - sinon.stub(planningApi, 'unlock'); - }); - - afterEach(() => { - restoreSinonStub(locks.unlock); - restoreSinonStub(locks.unlockThenLock); - restoreSinonStub(eventsApi.unlock); - restoreSinonStub(planningApi.unlock); - }); - - it('unlock on unknown item type calls locks.unlock', () => { - updateProps({ - itemId: testData.events[0]._id, - itemType: 'unknown', - }); - manager.unlock(); - - expect(eventsApi.unlock.callCount).toBe(0); - expect(planningApi.unlock.callCount).toBe(0); - expect(locks.unlock.callCount).toBe(1); - expect(locks.unlock.args[0]).toEqual([{ - _id: testData.events[0]._id, - type: 'unknown', - }]); - }); - - it('unlock doesnt call any action on a temporary item', () => { - // No id specified - updateProps({itemType: newEvent.type}); - manager.unlock(); - expect(locks.unlock.callCount).toBe(0); - expect(eventsApi.unlock.callCount).toBe(0); - expect(planningApi.unlock.callCount).toBe(0); - - // Id is for a temporary item - updateProps({itemId: newEvent._id}); - manager.unlock(); - expect(locks.unlock.callCount).toBe(0); - expect(eventsApi.unlock.callCount).toBe(0); - expect(planningApi.unlock.callCount).toBe(0); - }); - - it('unlock on Event calls events.api.unlock', () => { - updateProps({ - itemId: testData.events[0]._id, - itemType: testData.events[0].type, - }); - manager.unlock(); - - expect(locks.unlock.callCount).toBe(0); - expect(planningApi.unlock.callCount).toBe(0); - expect(eventsApi.unlock.callCount).toBe(1); - expect(eventsApi.unlock.args[0]).toEqual([{ - _id: testData.events[0]._id, - type: testData.events[0].type, - }]); - }); - - it('unlock on Planning calls planning.api.unlock', () => { - updateProps({ - itemId: testData.plannings[0]._id, - itemType: testData.plannings[0].type, - }); - manager.unlock(); - - expect(locks.unlock.callCount).toBe(0); - expect(eventsApi.unlock.callCount).toBe(0); - expect(planningApi.unlock.callCount).toBe(1); - expect(planningApi.unlock.args[0]).toEqual([{ - _id: testData.plannings[0]._id, - type: testData.plannings[0].type, - }]); - }); - - it('unlockThenLock calls locks.unlockThenLock', (done) => { - manager.unlockThenLock(testData.events[0]) - .then(() => { - expect(locks.unlockThenLock.callCount).toBe(1); - expect(locks.unlockThenLock.args[0]).toEqual([testData.events[0], false]); - - done(); - }) - .catch(done.fail); - }); - }); - describe('unlockAndCancel', () => { beforeEach(() => { - sinon.stub(manager, 'unlock').returns(Promise.resolve()); - sinon.stub(planningApi, 'unlock').returns(Promise.resolve()); + sinon.stub(planningApi.locks, 'unlockItem').returns(Promise.resolve()); }); afterEach(() => { - restoreSinonStub(manager.unlock); - restoreSinonStub(planningApi.unlock); + restoreSinonStub(planningApi.locks.unlockItem); }); it('doesnt call unlock if the item isnt locked', (done) => { @@ -1625,7 +1530,7 @@ describe('components.Main.ItemManager', () => { manager.unlockAndCancel() .then(() => { - expect(manager.unlock.callCount).toBe(0); + expect(planningApi.locks.unlockItem.callCount).toBe(0); done(); }) @@ -1648,8 +1553,7 @@ describe('components.Main.ItemManager', () => { manager.unlockAndCancel() .then(() => { - expect(manager.unlock.callCount).toBe(1); - expect(planningApi.unlock.callCount).toBe(0); + expect(planningApi.locks.unlockItem.callCount).toBe(0); expect(editor.autoSave.remove.callCount).toBe(1); expect(editor.closeEditor.callCount).toBe(1); @@ -1671,8 +1575,7 @@ describe('components.Main.ItemManager', () => { manager.unlockAndCancel() .then(() => { - expect(manager.unlock.callCount).toBe(0); - expect(planningApi.unlock.callCount).toBe(1); + expect(planningApi.locks.unlockItem.callCount).toBe(0); done(); }) @@ -1726,7 +1629,7 @@ describe('components.Main.ItemManager', () => { describe('addCoverageToWorkflow/removeAssignment', () => { beforeEach(() => { sinon.stub(manager, 'finalisePartialSave'); - sinon.stub(planningUi, 'addCoverageToWorkflow') + sinon.stub(planningApi.planning.coverages, 'addCoverageToWorkflow') .callsFake((planning, coverage, index) => { const updates = { ...cloneDeep(planning), @@ -1760,7 +1663,7 @@ describe('components.Main.ItemManager', () => { afterEach(() => { restoreSinonStub(manager.finalisePartialSave); - restoreSinonStub(planningUi.addCoverageToWorkflow); + restoreSinonStub(planningApi.planning.coverages.addCoverageToWorkflow); restoreSinonStub(planningUi.removeAssignment); }); @@ -1771,8 +1674,8 @@ describe('components.Main.ItemManager', () => { 1 ) .then(() => { - expect(planningUi.addCoverageToWorkflow.callCount).toBe(1); - expect(planningUi.addCoverageToWorkflow.args[0]).toEqual([ + expect(planningApi.planning.coverages.addCoverageToWorkflow.callCount).toBe(1); + expect(planningApi.planning.coverages.addCoverageToWorkflow.args[0]).toEqual([ testData.plannings[0], testData.plannings[0].coverages[1], 1, diff --git a/client/components/Main/ListGroup.tsx b/client/components/Main/ListGroup.tsx index 82e469aa6..d3cbac9df 100644 --- a/client/components/Main/ListGroup.tsx +++ b/client/components/Main/ListGroup.tsx @@ -2,7 +2,7 @@ import React from 'react'; import moment from 'moment-timezone'; import {ListGroupItem} from './'; import {Group, Header} from '../UI/List'; -import {IEventOrPlanningItem, LIST_VIEW_TYPE, SORT_FIELD} from '../../interfaces'; +import {ICommonAdvancedSearchParams, IEventOrPlanningItem, LIST_VIEW_TYPE, SORT_FIELD} from '../../interfaces'; import {timeUtils} from '../../utils'; const TIME_COLUMN_MIN_WIDTH = { @@ -78,6 +78,7 @@ interface IProps { listViewType?: string; sortField?: string; listBoxGroupProps: {}; + searchParams:ICommonAdvancedSearchParams; } export class ListGroup extends React.Component { @@ -145,6 +146,7 @@ export class ListGroup extends React.Component { listViewType, sortField, listBoxGroupProps, + searchParams, } = this.props; // with defaults @@ -205,6 +207,7 @@ export class ListGroup extends React.Component { listViewType: listViewType, sortField: sortField, minTimeWidth: minTimeWidth, + searchParams: searchParams, }; if (indexItems) { diff --git a/client/components/Main/ListGroupItem.tsx b/client/components/Main/ListGroupItem.tsx index 821d628c9..5290de171 100644 --- a/client/components/Main/ListGroupItem.tsx +++ b/client/components/Main/ListGroupItem.tsx @@ -1,11 +1,10 @@ import React from 'react'; import {debounce, indexOf} from 'lodash'; - import { IEventListItemProps, IPlanningListItemProps, IEventOrPlanningItem, - IEventItem, IPlanningItem, IBaseListItemProps + IEventItem, IPlanningItem, IBaseListItemProps, ICommonAdvancedSearchParams } from '../../interfaces'; import {EventItem, EventItemWithPlanning} from '../Events'; @@ -27,6 +26,7 @@ interface IProps extends Omit< index: number; navigateDown?: boolean; minTimeWidth?: string; + searchParams?: ICommonAdvancedSearchParams; onDoubleClick(item: IEventOrPlanningItem): void; showRelatedPlannings(item: IEventItem): void; @@ -122,6 +122,7 @@ export class ListGroupItem extends React.Component { listViewType, sortField, minTimeWidth, + searchParams, } = this.props; const itemType = getItemType(item); @@ -151,6 +152,7 @@ export class ListGroupItem extends React.Component { ...itemProps, item: item as IEventItem, calendars: calendars, + filterLanguage: searchParams?.language, multiSelected: indexOf(selectedEventIds, item._id) !== -1, [EVENTS.ITEM_ACTIONS.EDIT_EVENT.actionName]: itemActions[EVENTS.ITEM_ACTIONS.EDIT_EVENT.actionName], @@ -193,6 +195,7 @@ export class ListGroupItem extends React.Component { contentTypes: contentTypes, agendas: agendas, date: date, + filterLanguage: searchParams?.language, onAddCoverageClick: onAddCoverageClick, multiSelected: indexOf(selectedPlanningIds, item._id) !== -1, showAddCoverage: showAddCoverage, diff --git a/client/components/Main/ListPanel.tsx b/client/components/Main/ListPanel.tsx index 590d95408..4b503e73e 100644 --- a/client/components/Main/ListPanel.tsx +++ b/client/components/Main/ListPanel.tsx @@ -5,7 +5,7 @@ import {superdeskApi} from '../../superdeskApi'; import {IDesk, IUser} from 'superdesk-api'; import { FILTER_TYPE, - IAgenda, ICalendar, IContactItem, + IAgenda, ICalendar, ICommonAdvancedSearchParams, IContactItem, IEventItem, IEventOrPlanningItem, IG2ContentType, ILockedItems, @@ -68,6 +68,7 @@ interface IProps { listViewType: LIST_VIEW_TYPE; sortField: SORT_FIELD; userInitiatedSearch?: boolean; + searchParams?: ICommonAdvancedSearchParams onItemClick(item: IEventOrPlanningItem): void; onDoubleClick(item: IEventOrPlanningItem): void; @@ -318,6 +319,7 @@ export class ListPanel extends React.Component { contacts, listViewType, sortField, + searchParams } = this.props; let indexFrom = 0; @@ -396,6 +398,7 @@ export class ListPanel extends React.Component { listViewType: listViewType, sortField: sortField, listBoxGroupProps: listBoxGroupProps, + searchParams: searchParams, ...propsForNestedListItems, }; diff --git a/client/components/Planning/FeaturedPlanning/FeaturedPlanningItem.tsx b/client/components/Planning/FeaturedPlanning/FeaturedPlanningItem.tsx index b85703fdb..5518c80f7 100644 --- a/client/components/Planning/FeaturedPlanning/FeaturedPlanningItem.tsx +++ b/client/components/Planning/FeaturedPlanning/FeaturedPlanningItem.tsx @@ -9,6 +9,7 @@ import {renderFields} from '../../fields'; import { planningUtils, + lockUtils, getItemId, isItemExpired, gettext, @@ -36,7 +37,7 @@ export const FeaturedPlanningItem = ({ return null; } - const isItemLocked = planningUtils.isPlanningLocked(item, lockedItems); + const isItemLocked = lockUtils.isItemLocked(item, lockedItems); const isExpired = isItemExpired(item); let borderState = false; diff --git a/client/components/Planning/PlanningDateTime.tsx b/client/components/Planning/PlanningDateTime.tsx index 2985c8576..204514943 100644 --- a/client/components/Planning/PlanningDateTime.tsx +++ b/client/components/Planning/PlanningDateTime.tsx @@ -2,10 +2,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import {get} from 'lodash'; import moment from 'moment'; - import {planningUtils} from '../../utils/index'; import {MAIN} from '../../constants'; -import {CoverageIcon} from '../Coverages/'; +import {CoverageIcons} from '../Coverages/CoverageIcons'; export const PlanningDateTime = ({ item, @@ -46,22 +45,12 @@ export const PlanningDateTime = ({ }); return ( - - {coverageToDisplay.map((coverage, i) => ( - - ) - )} - + ); }; diff --git a/client/components/Planning/PlanningEditor/index.tsx b/client/components/Planning/PlanningEditor/index.tsx index 97d9439af..261a0927d 100644 --- a/client/components/Planning/PlanningEditor/index.tsx +++ b/client/components/Planning/PlanningEditor/index.tsx @@ -15,13 +15,14 @@ import { IPlanningFormProfile, IPlanningItem, IPlanningNewsCoverageStatus, + ILockedItems, } from '../../../interfaces'; import {IArticle, IDesk, IUser, IVocabularyItem} from 'superdesk-api'; import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; import * as selectors from '../../../selectors'; -import {planningUtils, eventUtils} from '../../../utils'; +import {planningUtils, eventUtils, lockUtils} from '../../../utils'; import {EditorForm} from '../../Editor/EditorForm'; import {PlanningEditorHeader} from './PlanningEditorHeader'; @@ -56,6 +57,7 @@ interface IProps { defaultDesk?: IDesk; preferredCoverageDesks: {[key: string]: string}; files: Array; + lockedItems: ILockedItems; onChangeHandler( field: string | {[key: string]: any}, @@ -90,6 +92,7 @@ const mapStateToProps = (state) => ({ files: selectors.general.files(state), contentTypes: selectors.general.contentTypes(state), formProfile: selectors.forms.planningProfile(state), + lockedItems: selectors.locks.getLockedItems(state), }); const mapDispatchToProps = (dispatch) => ({ @@ -209,7 +212,7 @@ class PlanningEditorComponent extends React.Component { } handleAddToPlanningLoading() { - if ((this.props.itemExists && !planningUtils.isLockedForAddToPlanning(this.props.item)) || + if ((this.props.itemExists && !lockUtils.isLockedForAddToPlanning(this.props.item, this.props.lockedItems)) || (!this.props.itemExists && get(this.props, 'diff.coverages.length', 0) > 0) ) { return; diff --git a/client/components/Planning/PlanningItem.tsx b/client/components/Planning/PlanningItem.tsx index b288336c6..b651f19e3 100644 --- a/client/components/Planning/PlanningItem.tsx +++ b/client/components/Planning/PlanningItem.tsx @@ -22,6 +22,7 @@ import {CreatedUpdatedColumn} from '../UI/List/CreatedUpdatedColumn'; import { eventUtils, planningUtils, + lockUtils, onEventCapture, isItemPosted, getItemId, @@ -31,6 +32,7 @@ import { } from '../../utils'; import {renderFields} from '../fields'; import * as actions from '../../actions'; +import {getUserInterfaceLanguageFromCV} from '../../utils/users'; interface IState { hover: boolean; @@ -63,7 +65,8 @@ class PlanningItemComponent extends React.Component { planningUtils.getAgendaNames(this.props.item, this.props.agendas), planningUtils.getAgendaNames(nextProps.item, nextProps.agendas) ) || - this.props.minTimeWidth !== nextProps.minTimeWidth; + this.props.minTimeWidth !== nextProps.minTimeWidth || + this.props.filterLanguage !== nextProps.filterLanguage; } onItemHoverOn() { @@ -183,6 +186,7 @@ class PlanningItemComponent extends React.Component { agendas, contacts, listViewType, + filterLanguage } = this.props; if (!item) { @@ -190,12 +194,13 @@ class PlanningItemComponent extends React.Component { } const {gettext} = superdeskApi.localization; - const isItemLocked = planningUtils.isPlanningLocked(item, lockedItems); + const isItemLocked = lockUtils.isItemLocked(item, lockedItems); const event = get(item, 'event'); const borderState = isItemLocked ? 'locked' : false; const isExpired = isItemExpired(item); const secondaryFields = get(listFields, 'planning.secondary_fields', PLANNING.LIST.SECONDARY_FIELDS); const {querySelectorParent} = superdeskApi.utilities; + const language = filterLanguage || item.language || getUserInterfaceLanguageFromCV(); return ( { {renderFields(get(listFields, 'planning.primary_fields', - PLANNING.LIST.PRIMARY_FIELDS), item)} + PLANNING.LIST.PRIMARY_FIELDS), item, {}, language)} {event && ( @@ -244,7 +249,7 @@ class PlanningItemComponent extends React.Component { )} - + {/** overflow is needed for coverage icons */} {isExpired && (