From a15abe93c49939ac587980091795abb372c1ef00 Mon Sep 17 00:00:00 2001 From: Tomas Kikutis Date: Tue, 3 Dec 2024 13:39:56 +0100 Subject: [PATCH] Adding related items to plannings and events (#2110) --- .eslintrc.js | 3 + client/actions/events/api.ts | 117 ++++++++- client/actions/events/notifications.ts | 48 ++-- client/actions/main.ts | 5 +- client/actions/planning/notifications.ts | 47 ++-- .../planning/tests/notifications_test.ts | 8 - client/api/editor/events.ts | 9 +- client/api/editor/form.ts | 4 +- client/api/editor/item.ts | 7 +- client/api/editor/item_events.ts | 50 ++-- client/api/editor/item_planning.ts | 3 - client/api/events.ts | 12 +- client/api/locks.ts | 10 + client/api/planning.ts | 6 +- client/components/Coverages/CoverageIcons.tsx | 6 +- .../components/Editor/EditorBookmarksBar.tsx | 4 +- client/components/Editor/EditorGroup.tsx | 10 +- client/components/Events/EventItem.tsx | 5 + .../EventMetadata/RelatedEventListItem.tsx | 6 +- .../components/Events/EventMetadata/index.tsx | 62 +++-- .../Main/ItemEditor/EditorHeader.tsx | 6 +- .../Main/ItemEditor/tests/ItemManager_test.ts | 2 + client/components/MultiSelectActions.tsx | 59 ++++- .../Planning/PlanningEditor/index.tsx | 3 +- client/components/Planning/PlanningItem.tsx | 8 + client/components/UI/List/Item.tsx | 4 + .../fields/editor/AssignedCoverage.tsx | 5 +- .../fields/editor/AssociatedEvent.tsx | 123 ++++++++-- .../EventsRelatedArticlesModal.tsx | 5 +- .../EventRelatedPlannings/AddNewCoverages.tsx | 16 +- .../EmbeddedCoverageForm.tsx | 5 +- .../EventRelatedPlannings.tsx | 155 +++++++----- .../RelatedPlanningItem.tsx | 5 +- client/config.ts | 6 +- client/configure.ts | 8 +- client/constants/events.ts | 2 +- client/constants/locks.ts | 1 + client/globals.d.ts | 46 ++++ client/interfaces.ts | 67 +++--- client/planning-extension/src/extension.ts | 7 +- client/planning-extension/src/globals.d.ts | 41 ++++ client/reducers/events.ts | 10 +- client/reducers/locks.ts | 32 ++- client/reducers/tests/locks_test.ts | 17 +- client/selectors/forms.ts | 11 + client/selectors/main.ts | 3 +- client/selectors/tests/main_test.ts | 8 +- client/utils/archive.ts | 3 +- client/utils/confirmAddingRelatedItems.tsx | 88 +++++++ client/utils/editor.ts | 7 + client/utils/{events.ts => events.tsx} | 226 +++++++++++++++++- client/utils/{planning.ts => planning.tsx} | 187 ++++++++++++++- client/utils/testData.ts | 79 +++--- client/validators/assignments.tsx | 4 +- e2e/RUNNING_LOCALLY.md | 8 + server/features/events.feature | 49 +++- server/planning/__init__.py | 3 + server/planning/planning/planning.py | 30 +++ server/planning/settings.py | 1 + server/planning/utils.py | 4 +- server/planning/validate/planning_validate.py | 3 +- 61 files changed, 1403 insertions(+), 366 deletions(-) create mode 100644 client/utils/confirmAddingRelatedItems.tsx create mode 100644 client/utils/editor.ts rename client/utils/{events.ts => events.tsx} (87%) rename client/utils/{planning.ts => planning.tsx} (90%) create mode 100644 e2e/RUNNING_LOCALLY.md create mode 100644 server/planning/settings.py diff --git a/.eslintrc.js b/.eslintrc.js index b434080d0..e208511c2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,6 +12,9 @@ module.exports = Object.assign({}, sharedConfigs, { 'camelcase': 0, 'no-prototype-builtins': 0, // allow hasOwnProperty 'react/prop-types': 0, // using interfaces + + // can make functions harder to read; forces into rewriting the function to insert a debugger + 'arrow-body-style': 0, }, }, { diff --git a/client/actions/events/api.ts b/client/actions/events/api.ts index 649519853..c8aefddc6 100644 --- a/client/actions/events/api.ts +++ b/client/actions/events/api.ts @@ -1,6 +1,6 @@ -import {get, isEqual, cloneDeep, pickBy, has, find, every, take} from 'lodash'; +import {get, cloneDeep, has, find, every, take} from 'lodash'; -import {planningApi} from '../../superdeskApi'; +import {planningApi, superdeskApi} from '../../superdeskApi'; import {ISearchSpikeState, IEventSearchParams, IEventItem, IPlanningItem, IEventTemplate} from '../../interfaces'; import {appConfig} from 'appConfig'; @@ -9,6 +9,7 @@ import { POST_STATE, MAIN, TO_BE_CONFIRMED_FIELD, + TEMP_ID_PREFIX, } from '../../constants'; import * as selectors from '../../selectors'; import { @@ -19,7 +20,6 @@ import { isPublishedItemId, isTemporaryId, gettext, - getTimeZoneOffset, } from '../../utils'; import planningApis from '../planning/api'; @@ -27,6 +27,8 @@ import eventsUi from './ui'; import main from '../main'; import {eventParamsToSearchParams} from '../../utils/search'; import {getRelatedEventIdsForPlanning} from '../../utils/planning'; +import {planning} from '../../api/planning'; +import * as actions from '../../actions'; /** * Action dispatcher to load a series of recurring events into the local store. @@ -480,11 +482,11 @@ function markEventPostponed(event: IEventItem, reason: string, actionedDate: str }; } -const markEventHasPlannings = (event, planning) => ({ - type: EVENTS.ACTIONS.MARK_EVENT_HAS_PLANNINGS, +const setEventPlannings = (event_id, planning_ids) => ({ + type: EVENTS.ACTIONS.SET_EVENT_PLANNINGS, payload: { - event_id: event, - planning_item: planning, + event_id, + planning_ids, }, }); @@ -547,6 +549,90 @@ const uploadFiles = (event) => ( } ); +function updateLinkedPlanningsForEvent( + eventId: IEventItem['_id'], + + /** + * these must be final values + * missing items will be linked, extra items unlinked + */ + associatedPlannings: Array, +):Promise { + return planningApi.events.getLinkedPlanningItems(eventId).then((currentlyLinked) => { + const currentLinkedIds = new Set(currentlyLinked.map((item) => item._id)); + + const toLink: Array = + associatedPlannings.filter(({_id}) => currentLinkedIds.has(_id) !== true); + + const toUnlink: Array = currentlyLinked + .filter((item) => { + const createdAt = new Date(item._created); + const now = new Date(); + + const ageSeconds = (now.getTime() - createdAt.getTime()) / 1000; + const tooRecent = ageSeconds < 30; + + if (tooRecent) { + /** + * This is a hack to workaround our existing "fake ID" workaround. + * In event editor it is possible to create a planning item and relate it to current event at once. + * It would happen only after saving, thus while it's not saved yet, we use a fake ID + * - which will not remain the same after saving. + * This function computes a list of planning items which have to be linked + * and which have to be unlinked based on a desired outcome + * which is that only items specified in {@link associatedPlannings} must remain linked. + * The problem arises that when item with a fake ID is saved, and ID changes, + * that item will immediately get unlinked by this function, because there is no way that + * the new ID could have been a part of {@link associatedPlannings} + * (which is computed before saving). + */ + return false; + } else { + const needToUnlink = associatedPlannings.find(({_id}) => _id === item._id) == null; + + return needToUnlink; + } + }); + + return Promise.all( + [ + ...toLink.map((planningItem) => { + const linkType = planningItem._temporary?.link_type; + + if (linkType == null) { + superdeskApi.utilities.logger.error( + new Error('linkType expected but not found'), + ); + + return Promise.resolve(planningItem); + } + + const patch: Partial = { + related_events: [ + ...(planningItem.related_events ?? []), + {_id: eventId, link_type: linkType}, + ], + }; + + return planning.update(planningItem, patch); + }), + ...toUnlink.map((planningItem) => { + const patch: Partial = { + related_events: (planningItem.related_events ?? []) + .filter((item) => item._id !== eventId), + }; + + return planning.update(planningItem, patch); + }), + ], + ).then((updatedPlanningItems) => { + planningApi.redux.store.dispatch(planningApis.receivePlannings(updatedPlanningItems)); + + return null; + }); + }); +} + const save = (original, updates) => ( (dispatch) => { let promise; @@ -561,7 +647,7 @@ const save = (original, updates) => ( promise = Promise.resolve({}); } - return promise.then((originalEvent) => { + return promise.then((originalEvent): any => { const originalItem = eventUtils.modifyForServer(cloneDeep(originalEvent), true); const eventUpdates = eventUtils.getEventDiff(originalItem, updates); @@ -574,9 +660,20 @@ const save = (original, updates) => ( EVENTS.UPDATE_METHODS[0].value : eventUpdates.update_method?.value ?? eventUpdates.update_method; - return originalEvent?._id != null ? + const createOrUpdatePromise: Promise> = originalEvent?._id != null ? planningApi.events.update(originalItem, eventUpdates) : planningApi.events.create(eventUpdates); + + return createOrUpdatePromise.then(([updatedEvent]: Array) => { + if (updates.associated_plannings == null) { + return Promise.resolve([updatedEvent]); + } + + return updateLinkedPlanningsForEvent( + updatedEvent._id, + updates.associated_plannings.filter(({_id}) => !_id.startsWith(TEMP_ID_PREFIX)), + ).then(() => [updatedEvent]); + }); }); } ); @@ -757,7 +854,7 @@ const self = { silentlyFetchEventsById, cancelEvent, markEventCancelled, - markEventHasPlannings, + setEventPlannings, rescheduleEvent, updateEventTime, markEventPostponed, diff --git a/client/actions/events/notifications.ts b/client/actions/events/notifications.ts index 0da3a75cd..9761809eb 100644 --- a/client/actions/events/notifications.ts +++ b/client/actions/events/notifications.ts @@ -348,6 +348,12 @@ const onEventDeleted = (e, data) => ( } }); +const onEventLinkUpdated = (e, data: IWebsocketMessageData['EVENT_LINK_UPDATED']) => ( + (dispatch, getState) => { + dispatch(eventsApi.setEventPlannings(data.event, data.links)); + dispatch(main.fetchItemHistory({_id: data.event, type: ITEM_TYPE.EVENT})); + }); + // eslint-disable-next-line consistent-this const self = { onEventCreated, @@ -363,6 +369,7 @@ const self = { onEventPostChanged, onEventExpired, onEventDeleted, + onEventLinkUpdated, }; export const planningEventTemplateEvents = { @@ -386,27 +393,28 @@ export const planningEventTemplateEvents = { // Map of notification name and Action Event to execute self.events = { - 'events:created': () => (self.onEventCreated), - 'events:created:recurring': () => (self.onRecurringEventCreated), - 'events:updated': () => (self.onEventUpdated), - 'events:updated:recurring': () => (self.onEventUpdated), - 'events:lock': () => (self.onEventLocked), - 'events:unlock': () => (self.onEventUnlocked), - 'events:spiked': () => (self.onEventSpiked), - 'events:unspiked': () => (self.onEventUnspiked), - 'events:cancel': () => (self.onEventCancelled), - 'events:reschedule': () => (self.onEventScheduleChanged), - 'events:reschedule:recurring': () => (self.onEventScheduleChanged), - 'events:postpone': () => (self.onEventPostponed), - 'events:posted': () => (self.onEventPostChanged), - 'events:posted:recurring': () => (self.onEventPostChanged), - 'events:unposted': () => (self.onEventPostChanged), - 'events:unposted:recurring': () => (self.onEventPostChanged), - 'events:update_time': () => (self.onEventScheduleChanged), - 'events:update_time:recurring': () => (self.onEventScheduleChanged), - 'events:update_repetitions:recurring': () => (self.onEventScheduleChanged), + 'events:created': () => self.onEventCreated, + 'events:created:recurring': () => self.onRecurringEventCreated, + 'events:updated': () => self.onEventUpdated, + 'events:updated:recurring': () => self.onEventUpdated, + 'events:lock': () => self.onEventLocked, + 'events:unlock': () => self.onEventUnlocked, + 'events:spiked': () => self.onEventSpiked, + 'events:unspiked': () => self.onEventUnspiked, + 'events:cancel': () => self.onEventCancelled, + 'events:reschedule': () => self.onEventScheduleChanged, + 'events:reschedule:recurring': () => self.onEventScheduleChanged, + 'events:postpone': () => self.onEventPostponed, + 'events:posted': () => self.onEventPostChanged, + 'events:posted:recurring': () => self.onEventPostChanged, + 'events:unposted': () => self.onEventPostChanged, + 'events:unposted:recurring': () => self.onEventPostChanged, + 'events:update_time': () => self.onEventScheduleChanged, + 'events:update_time:recurring': () => self.onEventScheduleChanged, + 'events:update_repetitions:recurring': () => self.onEventScheduleChanged, 'events:expired': () => self.onEventExpired, - 'events:delete': () => (self.onEventDeleted), + 'events:delete': () => self.onEventDeleted, + 'event:link_updated': () => self.onEventLinkUpdated, ...planningEventTemplateEvents, }; diff --git a/client/actions/main.ts b/client/actions/main.ts index c6adcd033..5e314c0b2 100644 --- a/client/actions/main.ts +++ b/client/actions/main.ts @@ -1,9 +1,7 @@ import {get, isEmpty, isEqual, isNil, omit} from 'lodash'; import moment from 'moment'; -import {appConfig as config} from 'appConfig'; - -const appConfig = config as IPlanningConfig; +import {appConfig} from 'appConfig'; import {IUser} from 'superdesk-api'; import {planningApi, superdeskApi} from '../superdeskApi'; @@ -21,7 +19,6 @@ import { ITEM_TYPE, IEventTemplate, IEventItem, - IPlanningConfig, } from '../interfaces'; import { diff --git a/client/actions/planning/notifications.ts b/client/actions/planning/notifications.ts index 4e4138ca3..2abb1c8d3 100644 --- a/client/actions/planning/notifications.ts +++ b/client/actions/planning/notifications.ts @@ -1,6 +1,6 @@ import {get} from 'lodash'; -import {IWebsocketMessageData, ITEM_TYPE} from '../../interfaces'; +import {IWebsocketMessageData, ITEM_TYPE, IPlanningAppState} from '../../interfaces'; import {planningApi} from '../../superdeskApi'; import {gettext, lockUtils} from '../../utils'; @@ -10,7 +10,7 @@ import planning from './index'; import assignments from '../assignments/index'; import * as selectors from '../../selectors'; -import {events, fetchAgendas} from '../index'; +import {fetchAgendas} from '../index'; import main from '../main'; import {showModal, hideModal} from '../index'; import eventsPlanning from '../eventsPlanning'; @@ -34,12 +34,6 @@ const onPlanningCreated = (_e: {}, data: IWebsocketMessageData['PLANNING_CREATED return Promise.resolve(); } - // Update Redux store to mark Event's to have Planning items - for (let eventId of data.event_ids) { - dispatch(events.api.markEventHasPlannings(eventId, data.item)); - dispatch(main.fetchItemHistory({_id: eventId, type: ITEM_TYPE.EVENT})); - } - dispatch(main.setUnsetLoadingIndicator(true)); return dispatch(planning.ui.scheduleRefetch()) .then(() => dispatch(eventsPlanning.ui.scheduleRefetch())) @@ -53,7 +47,9 @@ const onPlanningCreated = (_e: {}, data: IWebsocketMessageData['PLANNING_CREATED */ const onPlanningUpdated = (_e: {}, data: IWebsocketMessageData['PLANNING_UPDATED']) => ( (dispatch, getState) => { - if (data.item == null) { + const updatedPlanningId = data.item; + + if (updatedPlanningId == null) { return Promise.resolve(); } else if (selectors.general.sessionId(getState()) === data.session && ( selectors.general.modalType(getState()) === MODALS.ADD_TO_PLANNING || @@ -64,12 +60,6 @@ const onPlanningUpdated = (_e: {}, data: IWebsocketMessageData['PLANNING_UPDATED return Promise.resolve(); } - // Update Redux store to mark Event's to have Planning items - for (let eventId of data.event_ids) { - dispatch(events.api.markEventHasPlannings(eventId, data.item)); - dispatch(main.fetchItemHistory({_id: eventId, type: ITEM_TYPE.EVENT})); - } - const promises = []; promises.push(dispatch(planning.ui.scheduleRefetch()) @@ -77,8 +67,8 @@ const onPlanningUpdated = (_e: {}, data: IWebsocketMessageData['PLANNING_UPDATED if (selectors.general.currentWorkspace(getState()) === WORKSPACE.ASSIGNMENTS) { const currentPreviewId = selectors.main.previewId(getState()); - if (currentPreviewId === data.item) { - dispatch(planning.api.fetchById(data.item, {force: true})); + if (currentPreviewId === updatedPlanningId) { + dispatch(planning.api.fetchById(updatedPlanningId, {force: true})); } } @@ -89,9 +79,26 @@ const onPlanningUpdated = (_e: {}, data: IWebsocketMessageData['PLANNING_UPDATED promises.push(dispatch(fetchAgendas())); } - promises.push(dispatch(main.fetchItemHistory({_id: data.item, type: ITEM_TYPE.PLANNING}))); - promises.push(dispatch(udpateAssignment(data.item))); - promises.push(dispatch(planning.featuredPlanning.getAndUpdateStoredPlanningItem(data.item))); + promises.push(dispatch(main.fetchItemHistory({_id: updatedPlanningId, type: ITEM_TYPE.PLANNING}))); + promises.push(dispatch(udpateAssignment(updatedPlanningId))); + promises.push(dispatch(planning.featuredPlanning.getAndUpdateStoredPlanningItem(updatedPlanningId))); + promises.push(new Promise((resolve) => { + const state: IPlanningAppState = getState(); + + if ( // check if websocket notification contains updates of items currently in store + state.planning.plannings[updatedPlanningId] != null + || (data.event_ids ?? []).some((id) => state.events.events[id] != null) + ) { + return planningApi.planning.getById(updatedPlanningId, true, true) + .then((latestPlanning: IPlanningItem) => { + planningApi.locks.reloadSoftLocksForRelatedEvents(latestPlanning); + + resolve(); + }); + } else { + resolve(); + } + })); return Promise.all(promises); } diff --git a/client/actions/planning/tests/notifications_test.ts b/client/actions/planning/tests/notifications_test.ts index 8ec247885..6e5b31d91 100644 --- a/client/actions/planning/tests/notifications_test.ts +++ b/client/actions/planning/tests/notifications_test.ts @@ -154,14 +154,12 @@ describe('actions.planning.notifications', () => { describe('`planning:created`', () => { beforeEach(() => { - sinon.stub(eventsApi, 'markEventHasPlannings').callsFake(() => (Promise.resolve())); sinon.stub(eventsPlanningUi, 'scheduleRefetch').callsFake(() => (Promise.resolve())); sinon.stub(planningUi, 'scheduleRefetch').callsFake(() => (Promise.resolve())); sinon.stub(main, 'setUnsetLoadingIndicator').callsFake(() => (Promise.resolve())); }); afterEach(() => { - restoreSinonStub(eventsApi.markEventHasPlannings); restoreSinonStub(planningUi.scheduleRefetch); restoreSinonStub(eventsPlanningUi.scheduleRefetch); restoreSinonStub(main.setUnsetLoadingIndicator); @@ -176,12 +174,6 @@ describe('actions.planning.notifications', () => { event_ids: [eventId] })) .then(() => { - expect(eventsApi.markEventHasPlannings.callCount).toBe(1); - expect(eventsApi.markEventHasPlannings.args[0]).toEqual([ - eventId, - data.plannings[1]._id, - ]); - expect(main.setUnsetLoadingIndicator.callCount).toBe(2); expect(main.setUnsetLoadingIndicator.args).toEqual([ [true], diff --git a/client/api/editor/events.ts b/client/api/editor/events.ts index 2052a5456..34b577518 100644 --- a/client/api/editor/events.ts +++ b/client/api/editor/events.ts @@ -104,8 +104,15 @@ export function getEventsInstance(type: EDITOR_TYPE): IEditorAPI['events'] { } function setEventsPlanningsToAdd(newState: Partial) { + const associatedPlannings = planningApi.editor(type).item.getAssociatedPlannings(); + if (newState.diff.type === 'event' && newState.diff.associated_plannings == null) { - newState.diff.associated_plannings = planningApi.editor(type).item.getAssociatedPlannings(); + newState.diff.associated_plannings = associatedPlannings; + } + + // needs to be set on initial values as well for correct computation of state.dirty + if (newState.initialValues.type === 'event' && newState.initialValues.associated_plannings == null) { + newState.initialValues.associated_plannings = associatedPlannings; } } diff --git a/client/api/editor/form.ts b/client/api/editor/form.ts index 7932da0cb..9283979e3 100644 --- a/client/api/editor/form.ts +++ b/client/api/editor/form.ts @@ -22,10 +22,10 @@ export function getFormInstance(type: EDITOR_TYPE): IEditorAPI['form'] { return planningApi.editor(type).manager.getState(); } - function scrollToBookmarkGroup(bookmarkId: IEditorBookmarkGroup['group_id']) { + function scrollToBookmarkGroup(bookmarkId: IEditorBookmarkGroup['group_id'], options?: {focus?: boolean}) { const editor = planningApi.editor(type); - editor.dom.groups[bookmarkId]?.current?.scrollIntoView(); + editor.dom.groups[bookmarkId]?.current?.scrollIntoView({focus: options?.focus ?? true}); } function scrollToTop() { diff --git a/client/api/editor/item.ts b/client/api/editor/item.ts index 0cf586591..fcb719358 100644 --- a/client/api/editor/item.ts +++ b/client/api/editor/item.ts @@ -1,5 +1,5 @@ import {cloneDeep} from 'lodash'; -import {EDITOR_TYPE, IEditorAPI} from '../../interfaces'; +import {EDITOR_TYPE, IEditorAPI, IEditorProps} from '../../interfaces'; import {planningApi} from '../../superdeskApi'; import * as selectors from '../../selectors'; @@ -19,6 +19,10 @@ export function getItemInstance(type: EDITOR_TYPE): IEditorAPI['item'] { return planningApi.editor(type).form.getProps().itemId; } + function getItemAction(): IEditorProps['itemAction'] { + return planningApi.editor(type).form.getProps().itemAction; + } + function getAssociatedPlannings() { const state = planningApi.redux.store.getState(); const eventId = planningApi.editor(type).item.getItemId(); @@ -37,6 +41,7 @@ export function getItemInstance(type: EDITOR_TYPE): IEditorAPI['item'] { planning, getItemType, getItemId, + getItemAction, getAssociatedPlannings, }; } diff --git a/client/api/editor/item_events.ts b/client/api/editor/item_events.ts index 7650b735c..a2c3e39ba 100644 --- a/client/api/editor/item_events.ts +++ b/client/api/editor/item_events.ts @@ -17,7 +17,6 @@ import {planningApi, superdeskApi} from '../../superdeskApi'; import {generateTempId} from '../../utils'; import {getBookmarksFromFormGroups, getEditorFormGroupsFromProfile} from '../../utils/contentProfiles'; -import {TEMP_ID_PREFIX} from '../../constants'; import {AddPlanningBookmark, AssociatedPlanningsBookmark} from '../../components/Editor/bookmarks'; import {RelatedPlanningItem} from '../../components/fields/editor/EventRelatedPlannings/RelatedPlanningItem'; @@ -75,38 +74,49 @@ export function getEventsInstance(type: EDITOR_TYPE): IEditorAPI['item']['events return editor.dom.fields[field]; } - function addPlanningItem() { + function addPlanningItem( + item?: IPlanningItem, + options?: { + scrollIntoViewAndFocus?: boolean; + }, + ): Promise> { const editor = planningApi.editor(type); const event = editor.form.getDiff(); const plans = cloneDeep(event.associated_plannings || []); - const id = generateTempId(); - const newPlanningItem: Partial = { - _id: id, - ...convertEventToPlanningItem(event as IEventItem), - }; + const newPlanningItem = (() => { + if (item == null) { + const newPlanningItem: Partial = { + _id: generateTempId(), + ...convertEventToPlanningItem(event as IEventItem), + }; + + return newPlanningItem; + } else { + return item; + } + })(); plans.push(newPlanningItem); - editor.form.changeField('associated_plannings', plans) + return editor.form.changeField('associated_plannings', plans) .then(() => { - const node = getRelatedPlanningDomRef(id); - - if (node.current != null) { - node.current.scrollIntoView(); - editor.form.waitForScroll().then(() => { - node.current.focus(); - }); + if (options?.scrollIntoViewAndFocus ?? true) { + const node = getRelatedPlanningDomRef(newPlanningItem._id); + + if (node.current != null) { + node.current.scrollIntoView(); + editor.form.waitForScroll().then(() => { + node.current.focus(); + }); + } } + + return newPlanningItem; }); } function removePlanningItem(item: DeepPartial) { - if (!item._id.startsWith(TEMP_ID_PREFIX)) { - // We don't support removing existing Planning items - return; - } - const editor = planningApi.editor(type); const event = editor.form.getDiff(); const plans = (event.associated_plannings || []).filter( diff --git a/client/api/editor/item_planning.ts b/client/api/editor/item_planning.ts index f04426226..d35594585 100644 --- a/client/api/editor/item_planning.ts +++ b/client/api/editor/item_planning.ts @@ -47,9 +47,6 @@ export function getPlanningInstance(type: EDITOR_TYPE): IEditorAPI['item']['plan const profile = planningApi.contentProfiles.get('planning'); const groups = getEditorFormGroupsFromProfile(profile); - if (getRelatedEventLinksForPlanning(item).length === 0) { - delete groups['associated_event']; - } const bookmarks = getBookmarksFromFormGroups(groups); let index = bookmarks.length; diff --git a/client/api/events.ts b/client/api/events.ts index 0314025c1..b8a31a7c6 100644 --- a/client/api/events.ts +++ b/client/api/events.ts @@ -5,11 +5,11 @@ import { ISearchAPIParams, ISearchParams, ISearchSpikeState, - IPlanningConfig, IEventUpdateMethod, IGetRequestParams, + IPlanningItem, } from '../interfaces'; -import {appConfig as config} from 'appConfig'; +import {appConfig} from 'appConfig'; import {IRestApiResponse} from 'superdesk-api'; import {planningApi, superdeskApi} from '../superdeskApi'; import {EVENTS, TEMP_ID_PREFIX} from '../constants'; @@ -18,8 +18,7 @@ import {arrayToString, convertCommonParams, cvsToString, searchRaw, searchRawGet import {eventUtils} from '../utils'; import {eventProfile, eventSearchProfile} from '../selectors/forms'; import planningApis from '../actions/planning/api'; - -const appConfig = config as IPlanningConfig; +import {searchPlanning} from './planning'; function convertEventParams(params: ISearchParams): Partial { return { @@ -219,6 +218,10 @@ function update(original: IEventItem, updates: Partial): Promise> { + return searchPlanning({only_future: false, event_item: [eventId]}).then(({_items}) => _items); +} + export const events: IPlanningAPI['events'] = { search: searchEvents, searchGetAll: searchEventsGetAll, @@ -228,4 +231,5 @@ export const events: IPlanningAPI['events'] = { getSearchProfile: getEventSearchProfile, create: create, update: update, + getLinkedPlanningItems: getLinkedPlanningItems, }; diff --git a/client/api/locks.ts b/client/api/locks.ts index 39102411f..1e272a3e0 100644 --- a/client/api/locks.ts +++ b/client/api/locks.ts @@ -101,6 +101,15 @@ function setItemAsUnlocked(data: IWebsocketMessageData['ITEM_UNLOCKED']): void { }); } +function reloadSoftLocksForRelatedEvents(planning: IPlanningItem): void { + const {dispatch} = planningApi.redux.store; + + dispatch({ + type: LOCKS.ACTIONS.RELOAD_SOFT_LOCKS_FOR_RELATED_EVENTS, + payload: {planning}, + }); +} + function getLockResourceName(itemType: IAssignmentOrPlanningItem['type']) { switch (itemType) { case 'event': @@ -335,6 +344,7 @@ export const locks: IPlanningAPI['locks'] = { loadLockedItems: loadLockedItems, setItemAsLocked: setItemAsLocked, setItemAsUnlocked: setItemAsUnlocked, + reloadSoftLocksForRelatedEvents: reloadSoftLocksForRelatedEvents, lockItem: lockItem, lockItemById: lockItemById, unlockItem: unlockItem, diff --git a/client/api/planning.ts b/client/api/planning.ts index c1ce2861e..ac8ba2619 100644 --- a/client/api/planning.ts +++ b/client/api/planning.ts @@ -9,9 +9,9 @@ import { ISearchAPIParams, ISearchParams, ISearchSpikeState, - IPlanningConfig, IPlanningRelatedEventLink, + IPlanningRelatedEventLink, } from '../interfaces'; -import {appConfig as config} from 'appConfig'; +import {appConfig} from 'appConfig'; import {arrayToString, convertCommonParams, searchRaw, searchRawGetAll, cvsToString} from './search'; import {planningApi, superdeskApi} from '../superdeskApi'; @@ -23,8 +23,6 @@ import {PLANNING} from '../constants'; import * as selectors from '../selectors'; import planningApis from '../actions/planning/api'; -const appConfig = config as IPlanningConfig; - export function convertPlanningParams(params: ISearchParams): Partial { return { agendas: arrayToString(params.agendas), diff --git a/client/components/Coverages/CoverageIcons.tsx b/client/components/Coverages/CoverageIcons.tsx index 5b308b954..3b9b49023 100644 --- a/client/components/Coverages/CoverageIcons.tsx +++ b/client/components/Coverages/CoverageIcons.tsx @@ -1,8 +1,8 @@ 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 {appConfig} from 'appConfig'; +import {IPlanningCoverageItem, IG2ContentType, IContactItem} from '../../interfaces'; import {IUser, IDesk} from 'superdesk-api'; import {superdeskApi} from '../../superdeskApi'; import { @@ -34,8 +34,6 @@ interface IProps { iconWrapper?(children: React.ReactNode): React.ReactNode; } -const appConfig = config.appConfig as IPlanningConfig; - export function isAvatarPlaceholder( item: Omit | Omit ): item is Omit { diff --git a/client/components/Editor/EditorBookmarksBar.tsx b/client/components/Editor/EditorBookmarksBar.tsx index 002d9ce55..e3e12e6fa 100644 --- a/client/components/Editor/EditorBookmarksBar.tsx +++ b/client/components/Editor/EditorBookmarksBar.tsx @@ -65,7 +65,9 @@ class EditorBookmarksBarComponent extends React.PureComponent { style="hollow" type="primary" expand={true} - onClick={editor.item.events.addPlanningItem} + onClick={() => { + editor.item.events.addPlanningItem(); + }} /> ); diff --git a/client/components/Editor/EditorGroup.tsx b/client/components/Editor/EditorGroup.tsx index 2addcbcba..348e18b4d 100644 --- a/client/components/Editor/EditorGroup.tsx +++ b/client/components/Editor/EditorGroup.tsx @@ -47,7 +47,7 @@ export class EditorGroup extends React.PureComponent implements IEditorR } } - scrollIntoView() { + scrollIntoView(options?: {focus?: boolean}) { if (this.dom.div.current != null) { this.dom.div.current.scrollIntoView({behavior: 'smooth'}); } else if (this.dom.toggle.current != null) { @@ -61,7 +61,13 @@ export class EditorGroup extends React.PureComponent implements IEditorR // Wait for scroll to complete, then attempt to focus the first field this.editorApi.form .waitForScroll() - .then(this.focus); + .then(() => { + const shouldFocus = options?.focus ?? true; + + if (shouldFocus) { + this.focus(); + } + }); } getBoundingClientRect() { diff --git a/client/components/Events/EventItem.tsx b/client/components/Events/EventItem.tsx index 98a27e545..e984e5ea9 100644 --- a/client/components/Events/EventItem.tsx +++ b/client/components/Events/EventItem.tsx @@ -197,6 +197,11 @@ class EventItemComponent extends React.Component { onMouseLeave={this.onItemHoverOff} onMouseEnter={this.onItemHoverOn} refNode={refNode} + draggable={!isItemLocked} + onDragstart={(dragEvent) => { + dragEvent.dataTransfer.setData('application/superdesk.planning.event', JSON.stringify(item)); + dragEvent.dataTransfer.effectAllowed = 'link'; + }} > { withExpiredStatus={true} /> - {!this.props.editEventComponent ? null : ( + {!this.props.eventActions ? null : ( - {this.props.editEventComponent} + {this.props.eventActions} )} diff --git a/client/components/Events/EventMetadata/index.tsx b/client/components/Events/EventMetadata/index.tsx index 0897e4761..072fdac88 100644 --- a/client/components/Events/EventMetadata/index.tsx +++ b/client/components/Events/EventMetadata/index.tsx @@ -24,6 +24,8 @@ import {FileInput, LinkInput} from '../../UI/Form'; import {Location} from '../../Location'; import {previewGroupToProfile, renderGroupedFieldsForPanel} from '../../fields'; import {RelatedEventListItem} from './RelatedEventListItem'; +import {IconButton, Spacer} from 'superdesk-ui-framework/react'; +import {superdeskApi} from '../../../superdeskApi'; interface IProps { event: IEventItem; @@ -33,6 +35,7 @@ interface IProps { testId?: string; onEditEvent?(): void; + onRemoveEvent?(): void; onOpen?(): void; onClick?(): void; createUploadLink(file: IFile): string; @@ -74,6 +77,7 @@ class EventMetadataComponent extends React.PureComponent { dateOnly, tabEnabled, onEditEvent, + onRemoveEvent, noOpen, onClick, navigation, @@ -92,19 +96,47 @@ class EventMetadataComponent extends React.PureComponent { true, false ); - const editEventComponent = onEditEvent && !hideEditIcon ? - ( - - ) : null; + + const {gettext} = superdeskApi.localization; + + const eventActions = (() => { + const showEditButton = onEditEvent && !hideEditIcon; + const showRemoveButton = onRemoveEvent != null; + + if (showEditButton !== true && showRemoveButton !== true) { + return null; + } else { + return ( + + { + showEditButton && ( + { + onEventCapture(event); + onEditEvent(); + }} + /> + ) + } + + { + showRemoveButton && ( + { + onEventCapture(event); + onRemoveEvent(); + }} + /> + ) + } + + ); + } + })(); const eventListView = ( { showBorder={showBorder} showIcon={showIcon} dateOnly={dateOnly} - editEventComponent={editEventComponent} + eventActions={eventActions} /> ); @@ -235,7 +267,7 @@ class EventMetadataComponent extends React.PureComponent { openItem={eventInDetail} scrollInView={scrollInView} tabEnabled={tabEnabled} - tools={editEventComponent} + tools={eventActions} noOpen={noOpen} isOpen={isOpen} onClose={onClose} diff --git a/client/components/Main/ItemEditor/EditorHeader.tsx b/client/components/Main/ItemEditor/EditorHeader.tsx index bb264ee14..8a4170759 100644 --- a/client/components/Main/ItemEditor/EditorHeader.tsx +++ b/client/components/Main/ItemEditor/EditorHeader.tsx @@ -250,9 +250,11 @@ export class EditorHeader extends React.Component { states.isEvent = itemType === ITEM_TYPE.EVENT; states.isPublic = isItemPublic(initialValues); - states.isEvent ? - this.getEventStates(states) : + if (states.isEvent) { + this.getEventStates(states); + } else { this.getPlanningStates(states); + } states.showUpdate = states.isPublic && states.canUpdate; states.showSave = !states.isPublic && states.canEdit; diff --git a/client/components/Main/ItemEditor/tests/ItemManager_test.ts b/client/components/Main/ItemEditor/tests/ItemManager_test.ts index d9d36b7b0..cf179ec88 100644 --- a/client/components/Main/ItemEditor/tests/ItemManager_test.ts +++ b/client/components/Main/ItemEditor/tests/ItemManager_test.ts @@ -41,6 +41,7 @@ describe('components.Main.ItemManager', () => { state: 'draft', language: 'en', languages: ['en'], + associated_plannings: [], }; newPlan = { @@ -653,6 +654,7 @@ describe('components.Main.ItemManager', () => { initialValues: { _id: 'tempId-e5', type: 'event', + associated_plannings: [], }, }; diff --git a/client/components/MultiSelectActions.tsx b/client/components/MultiSelectActions.tsx index 7c2e6ad8e..e22595219 100644 --- a/client/components/MultiSelectActions.tsx +++ b/client/components/MultiSelectActions.tsx @@ -7,6 +7,9 @@ import {eventUtils, planningUtils, gettext} from '../utils'; import {MAIN} from '../constants'; import {SlidingToolBar} from './UI/SubNav'; import {IEventItem, ILockedItems, IPlanningItem, IPrivileges, ISession} from 'interfaces'; +import {addSomeEventsAsRelatedToPlanningEditor, canAddSomeEventsAsRelatedToPlanningEditor} from '../utils/events'; +import {superdeskApi} from '../superdeskApi'; +import {addSomeRelatedPlanningsToEventEditor, canAddSomeRelatedPlanningsToEventEditor} from '../utils/planning'; import {IconButton} from 'superdesk-ui-framework'; interface IReduxState { @@ -92,6 +95,7 @@ export class MultiSelectActionsComponent extends React.PureComponent { } getPlanningTools() { + const {gettextPlural} = superdeskApi.localization; const { selectedPlannings, privileges, @@ -163,6 +167,26 @@ export class MultiSelectActionsComponent extends React.PureComponent { ); } + if (canAddSomeRelatedPlanningsToEventEditor(selectedPlannings, lockedItems)) { + tools.push( + { + addSomeRelatedPlanningsToEventEditor(selectedPlannings, lockedItems) + .then(() => { + this.handleDeSelectAll(); + }); + }} + icon="link" + ariaValue={gettextPlural( + selectedPlannings.length, + 'Add as related planning', + 'Add as related plannings', + )} + /> + ); + } + return tools; } @@ -189,6 +213,8 @@ export class MultiSelectActionsComponent extends React.PureComponent { (event) => eventUtils.canCreatePlanningFromEvent(event, session, privileges, lockedItems) ); + const {gettextPlural} = superdeskApi.localization; + let tools = [( { ); } + if (canAddSomeEventsAsRelatedToPlanningEditor(selectedEvents)) { + tools.push( + { + addSomeEventsAsRelatedToPlanningEditor(selectedEvents) + .then(() => { + this.handleDeSelectAll(); + }); + }} + icon="link" + ariaValue={gettextPlural(selectedEvents.length, 'Add as related event', 'Add as related events')} + /> + ); + } + return tools; } @@ -269,10 +311,19 @@ export class MultiSelectActionsComponent extends React.PureComponent { selectedEventIds, } = this.props; - const hideSlidingToolBar = (activeFilter === MAIN.FILTERS.PLANNING && - selectedPlanningIds.length === 0) || - (activeFilter === MAIN.FILTERS.EVENTS && selectedEventIds.length === 0) || - activeFilter === MAIN.FILTERS.COMBINED; + const hideSlidingToolBar = + ( + activeFilter === MAIN.FILTERS.PLANNING && + selectedPlanningIds.length === 0 + ) + || ( + activeFilter === MAIN.FILTERS.EVENTS && selectedEventIds.length === 0 + ) + || activeFilter === MAIN.FILTERS.COMBINED; + + if (hideSlidingToolBar) { + return null; + } let innerTools = [({gettext('Deselect All')})]; diff --git a/client/components/Planning/PlanningEditor/index.tsx b/client/components/Planning/PlanningEditor/index.tsx index 8e5d3e887..fc4667dc1 100644 --- a/client/components/Planning/PlanningEditor/index.tsx +++ b/client/components/Planning/PlanningEditor/index.tsx @@ -502,7 +502,8 @@ class PlanningEditorComponent extends React.Component { files: this.props.files, }, associated_event: { - events: (this.props.item.related_events ?? []) + field: 'related_events', + events: (this.props.diff.related_events ?? []) .map((relatedEvent) => this.props.events[relatedEvent._id]), }, coverages: { diff --git a/client/components/Planning/PlanningItem.tsx b/client/components/Planning/PlanningItem.tsx index f36c3e3b7..dee860c9b 100644 --- a/client/components/Planning/PlanningItem.tsx +++ b/client/components/Planning/PlanningItem.tsx @@ -223,6 +223,14 @@ class PlanningItemComponent extends React.Component { onMouseLeave={this.onItemHoverOff} onMouseEnter={this.onItemHoverOn} refNode={refNode} + draggable={!isItemLocked} + onDragstart={(dragEvent) => { + dragEvent.dataTransfer.setData( + 'application/superdesk.planning.planning_item', + JSON.stringify(item), + ); + dragEvent.dataTransfer.effectAllowed = 'link'; + }} > ): void; onFocus?(event: React.FocusEvent): void; onKeyDown?(event: React.KeyboardEvent): void; + onDragstart?: React.DragEventHandler; } export class Item extends React.PureComponent { @@ -44,6 +45,7 @@ export class Item extends React.PureComponent { refNode, tabIndex, draggable, + onDragstart, testId, } = this.props; @@ -72,6 +74,8 @@ export class Item extends React.PureComponent { onKeyDown={onKeyDown} ref={refNode} tabIndex={tabIndex} + draggable={draggable} + onDragStart={onDragstart} > {children} diff --git a/client/components/fields/editor/AssignedCoverage.tsx b/client/components/fields/editor/AssignedCoverage.tsx index 8196628b6..089f7637b 100644 --- a/client/components/fields/editor/AssignedCoverage.tsx +++ b/client/components/fields/editor/AssignedCoverage.tsx @@ -1,9 +1,8 @@ import * as React from 'react'; import {superdeskApi} from '../../../superdeskApi'; -import {ICoverageAssigned, IEditorFieldProps, IPlanningConfig} from '../../../interfaces'; +import {ICoverageAssigned, IEditorFieldProps} from '../../../interfaces'; import {EditorFieldSelect} from './base/select'; -import * as config from 'appConfig'; -const appConfig = config.appConfig as IPlanningConfig; +import {appConfig} from 'appConfig'; interface IProps extends IEditorFieldProps { contentTypes: Array; diff --git a/client/components/fields/editor/AssociatedEvent.tsx b/client/components/fields/editor/AssociatedEvent.tsx index e7ac62226..aac78c40f 100644 --- a/client/components/fields/editor/AssociatedEvent.tsx +++ b/client/components/fields/editor/AssociatedEvent.tsx @@ -1,12 +1,14 @@ import * as React from 'react'; import {connect} from 'react-redux'; -import {IEditorFieldProps, IEventItem, IFile, ILockedItems} from '../../../interfaces'; +import {IEditorFieldProps, IEventItem, IFile, ILockedItems, IPlanningRelatedEventLink} from '../../../interfaces'; import {getFileDownloadURL} from '../../../utils'; import * as selectors from '../../../selectors'; import {EventMetadata} from '../../Events'; +import {superdeskApi} from '../../../superdeskApi'; +import events from '../../../utils/events'; interface IProps extends IEditorFieldProps { events?: Array; @@ -15,28 +17,113 @@ interface IProps extends IEditorFieldProps { tabEnabled?: boolean; // defaults to true } -const mapStateToProps = (state) => ({ - lockedItems: selectors.locks.getLockedItems(state), - files: selectors.general.files(state), -}); - class EditorFieldAssociatedEventComponent extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.getCurrentValue = this.getCurrentValue.bind(this); + this.addRelatedEvent = this.addRelatedEvent.bind(this); + this.removeRelatedEvent = this.removeRelatedEvent.bind(this); + this.relatedItemExists = this.relatedItemExists.bind(this); + } + + private getCurrentValue(): Array { + const {field, item} = this.props; + const relatedEvents = item[field] ?? []; + + return relatedEvents; + } + + private addRelatedEvent(event: IEventItem) { + events.addSomeEventsAsRelatedToPlanningEditor([event], (nextItems) => { + this.props.onChange( + this.props.field, + nextItems, + ); + }); + } + + private removeRelatedEvent(id: IEventItem['_id']) { + this.props.onChange( + this.props.field, + this.getCurrentValue().filter((item) => item._id !== id), + ); + } + + private relatedItemExists(id: IEventItem['_id']) { + const {field, item} = this.props; + const relatedEvents = item[field] ?? []; + + return relatedEvents.find((event) => event._id === id); + } + render() { - return (this.props.events?.length ?? 0) < 1 ? null : this.props.events.map((event) => ( - - )); + const {gettext} = superdeskApi.localization; + const {DropZone} = superdeskApi.components; + const events = this.props.events ?? []; + const disabled = this.props.disabled ?? false; + + return ( +
+ + + { + events.map((event) => ( + { + this.removeRelatedEvent(event._id); + } + } + /> + )) + } + + { + !disabled && ( + event.dataTransfer.getData( + 'application/superdesk.planning.event', + ) != null + } + onDrop={(event) => { + event.preventDefault(); + + const eventItem: IEventItem = JSON.parse( + event.dataTransfer.getData('application/superdesk.planning.event'), + ); + + this.addRelatedEvent(eventItem); + }} + multiple={true} + > + {gettext('Drop events here')} + + ) + } +
+ ); } } +const mapStateToProps = (state) => ({ + lockedItems: selectors.locks.getLockedItems(state), + files: selectors.general.files(state), +}); + export const EditorFieldAssociatedEvents = connect( mapStateToProps, null, diff --git a/client/components/fields/editor/EventRelatedArticles/EventsRelatedArticlesModal.tsx b/client/components/fields/editor/EventRelatedArticles/EventsRelatedArticlesModal.tsx index 08cff86e3..d57e1df4e 100644 --- a/client/components/fields/editor/EventRelatedArticles/EventsRelatedArticlesModal.tsx +++ b/client/components/fields/editor/EventRelatedArticles/EventsRelatedArticlesModal.tsx @@ -1,9 +1,8 @@ import React from 'react'; import {IArticle, IRestApiResponse, ISuperdeskQuery} from 'superdesk-api'; -import {IPlanningConfig} from '../../../../interfaces'; import {superdeskApi} from '../../../../superdeskApi'; -import {appConfig as config} from 'appConfig'; +import {appConfig} from 'appConfig'; import {cleanArticlesFields} from './utils'; @@ -30,8 +29,6 @@ import {PreviewArticle} from './PreviewArticle'; import '../../../../components/Archive/ArchivePreview/style.scss'; -const appConfig = config as IPlanningConfig; - interface IProps { closeModal: () => void; selectedArticles?: Array>; diff --git a/client/components/fields/editor/EventRelatedPlannings/AddNewCoverages.tsx b/client/components/fields/editor/EventRelatedPlannings/AddNewCoverages.tsx index bd02f282d..4894c1b4a 100644 --- a/client/components/fields/editor/EventRelatedPlannings/AddNewCoverages.tsx +++ b/client/components/fields/editor/EventRelatedPlannings/AddNewCoverages.tsx @@ -8,7 +8,6 @@ import { IPlanningCoverageItem, IPlanningItem, IPlanningNewsCoverageStatus, - IPlanningConfig, IPlanningContentProfile, ISearchProfile } from '../../../../interfaces'; @@ -20,8 +19,7 @@ import {planningUtils, generateTempId} from '../../../../utils'; import {ButtonGroup, Button, IconLabel} from 'superdesk-ui-framework/react'; import {ICoverageDetails, CoverageRowForm} from './CoverageRowForm'; import {Group} from '../../../UI/List'; -import * as config from 'appConfig'; -const appConfig = config.appConfig as IPlanningConfig; +import {appConfig} from 'appConfig'; interface IProps { event: IEventItem; @@ -33,6 +31,7 @@ interface IProps { desks: Array; users: Array; updatePlanningItem(updates: DeepPartial): void; + initiallyExpanded?: boolean; } interface IState { @@ -57,10 +56,13 @@ class AddNewCoveragesComponent extends React.Component { constructor(props) { super(props); - this.state = { - ...this.getInitialState(), - inEditMode: !this.props.item.coverages?.length, - }; + const initialState = this.getInitialState(); + + if (this.props.initiallyExpanded) { + initialState.inEditMode = true; + } + + this.state = initialState; this.editForm = React.createRef(); diff --git a/client/components/fields/editor/EventRelatedPlannings/EmbeddedCoverageForm.tsx b/client/components/fields/editor/EventRelatedPlannings/EmbeddedCoverageForm.tsx index 969e5f8d7..145e65e86 100644 --- a/client/components/fields/editor/EventRelatedPlannings/EmbeddedCoverageForm.tsx +++ b/client/components/fields/editor/EventRelatedPlannings/EmbeddedCoverageForm.tsx @@ -4,7 +4,6 @@ import {connect} from 'react-redux'; import {IDesk, IUser, IVocabularyItem} from 'superdesk-api'; import { IPlanningNewsCoverageStatus, - IPlanningConfig, IPlanningContentProfile, IEventItem, ISearchProfile @@ -16,11 +15,9 @@ import {Select, Option} from 'superdesk-ui-framework/react'; import * as List from '../../../UI/List'; import {Row} from '../../../UI/Form'; import {EditorFieldNewsCoverageStatus} from '../NewsCoverageStatus'; -import * as config from 'appConfig'; +import {appConfig} from 'appConfig'; import {getLanguagesForTreeSelectInput} from '../../../../selectors/vocabs'; -const appConfig = config.appConfig as IPlanningConfig; - interface IProps { coverage: ICoverageDetails; language?: string; diff --git a/client/components/fields/editor/EventRelatedPlannings/EventRelatedPlannings.tsx b/client/components/fields/editor/EventRelatedPlannings/EventRelatedPlannings.tsx index e6a6a5174..840b2ca5a 100644 --- a/client/components/fields/editor/EventRelatedPlannings/EventRelatedPlannings.tsx +++ b/client/components/fields/editor/EventRelatedPlannings/EventRelatedPlannings.tsx @@ -1,8 +1,10 @@ import * as React from 'react'; +import {connect} from 'react-redux'; import { IEditorFieldProps, IEventItem, + ILockedItems, IPlanningCoverageItem, IPlanningItem, IProfileSchemaTypeList, @@ -10,92 +12,133 @@ import { } from '../../../../interfaces'; import {planningApi, superdeskApi} from '../../../../superdeskApi'; -import {ButtonGroup, Button} from 'superdesk-ui-framework/react'; -import {Row} from '../../../UI/Form'; +import {Button, Spacer} from 'superdesk-ui-framework/react'; import {RelatedPlanningItem} from './RelatedPlanningItem'; import {PlanningMetaData} from '../../../RelatedPlannings/PlanningMetaData'; import './style.scss'; +import {TEMP_ID_PREFIX} from '../../../../constants'; +import {addSomeRelatedPlanningsToEventEditor} from '../../../../utils/planning'; +import * as selectors from '../../../../selectors'; -interface IProps extends IEditorFieldProps { +interface IOwnProps extends IEditorFieldProps { item: IEventItem; schema?: IProfileSchemaTypeList; coverageProfile?: ISearchProfile; getRef(value: DeepPartial): React.RefObject; - addPlanningItem(): void; + addPlanningItem(item?: IPlanningItem): Promise>; removePlanningItem(item: DeepPartial): void; updatePlanningItem(original: DeepPartial, updates: DeepPartial): void; addCoverageToWorkflow(original: IPlanningItem, coverage: IPlanningCoverageItem, index: number): void; } -export class EditorFieldEventRelatedPlannings extends React.PureComponent { +interface IReduxProps { + lockedItems: ILockedItems; +} + +type IProps = IOwnProps & IReduxProps; + +export class EditorFieldEventRelatedPlanningsComponent extends React.PureComponent { render() { const {gettext} = superdeskApi.localization; + const {DropZone} = superdeskApi.components; const isAgendaEnabled = planningApi.planning.getEditorProfile().editor.agendas.enabled; const disabled = this.props.disabled || this.props.schema?.read_only; + const planningItems = this.props.item.associated_plannings ?? []; return (
- + + {disabled ? null : ( - -
); } } + +const mapStateToProps = (state): IReduxProps => ({ + lockedItems: selectors.locks.getLockedItems(state), +}); + + +export const EditorFieldEventRelatedPlannings = connect( + mapStateToProps, +)(EditorFieldEventRelatedPlanningsComponent); diff --git a/client/components/fields/editor/EventRelatedPlannings/RelatedPlanningItem.tsx b/client/components/fields/editor/EventRelatedPlannings/RelatedPlanningItem.tsx index 15f83a21d..0e2c1f3ff 100644 --- a/client/components/fields/editor/EventRelatedPlannings/RelatedPlanningItem.tsx +++ b/client/components/fields/editor/EventRelatedPlannings/RelatedPlanningItem.tsx @@ -12,7 +12,6 @@ import { } from '../../../../interfaces'; import {superdeskApi} from '../../../../superdeskApi'; -import {TEMP_ID_PREFIX} from '../../../../constants'; import {planningUtils} from '../../../../utils'; import {IconButton} from 'superdesk-ui-framework/react'; @@ -37,6 +36,7 @@ interface IProps { ): void; addCoverageToWorkflow(original: IPlanningItem, coverage: IPlanningCoverageItem, index: number): void; isAgendaEnabled: boolean; + initiallyExpanded?: boolean; } export class RelatedPlanningItem extends React.PureComponent { @@ -106,7 +106,7 @@ export class RelatedPlanningItem extends React.PureComponent { render() { const {gettext} = superdeskApi.localization; const {item, isAgendaEnabled} = this.props; - const hideRemoveIcon = !this.props.item._id.startsWith(TEMP_ID_PREFIX) || this.props.disabled; + const hideRemoveIcon = this.props.disabled; return (
{ updatePlanningItem={this.update} profile={this.props.profile} coverageProfile={this.props.coverageProfile} + initiallyExpanded={this.props.initiallyExpanded} /> )} diff --git a/client/config.ts b/client/config.ts index 5457be28d..6275e33e1 100644 --- a/client/config.ts +++ b/client/config.ts @@ -1,10 +1,8 @@ import moment from 'moment-timezone'; -import {IPlanningConfig, PLANNING_VIEW} from './interfaces'; +import {PLANNING_VIEW} from './interfaces'; import {appConfig} from 'appConfig'; -appConfig = appConfig as IPlanningConfig; - // Set the default values for Planning config entries if (appConfig.default_genre == null) { @@ -103,5 +101,3 @@ export function updateConfigAfterLoad() { appConfig.planning.autosave_timeout = 1500; } } - -export const planningConfig = appConfig as IPlanningConfig; diff --git a/client/configure.ts b/client/configure.ts index d76f3dc8b..59657aae0 100644 --- a/client/configure.ts +++ b/client/configure.ts @@ -1,6 +1,6 @@ -import {planningConfig} from './config'; -import {IPlanningConfig} from './interfaces'; +import {ISuperdeskGlobalConfig} from 'superdesk-api'; +import {appConfig} from 'appConfig'; -export const setCoverageDueDateStrategy = (callback: IPlanningConfig['coverage']['getDueDateStrategy']) => { - planningConfig.coverage.getDueDateStrategy = callback; +export const setCoverageDueDateStrategy = (callback: ISuperdeskGlobalConfig['coverage']['getDueDateStrategy']) => { + appConfig.coverage.getDueDateStrategy = callback; }; diff --git a/client/constants/events.ts b/client/constants/events.ts index c81763355..4661ef6d8 100644 --- a/client/constants/events.ts +++ b/client/constants/events.ts @@ -12,7 +12,7 @@ export const EVENTS = { RECEIVE_EVENT_HISTORY: 'RECEIVE_EVENT_HISTORY', MARK_EVENT_CANCELLED: 'MARK_EVENT_CANCELLED', MARK_EVENT_POSTPONED: 'MARK_EVENT_POSTPONED', - MARK_EVENT_HAS_PLANNINGS: 'MARK_EVENT_HAS_PLANNINGS', + SET_EVENT_PLANNINGS: 'SET_EVENT_PLANNINGS', LOCK_EVENT: 'LOCK_EVENT', UNLOCK_EVENT: 'UNLOCK_EVENT', MARK_EVENT_POSTED: 'MARK_EVENT_POSTED', diff --git a/client/constants/locks.ts b/client/constants/locks.ts index d37f34f7d..86ff6d45d 100644 --- a/client/constants/locks.ts +++ b/client/constants/locks.ts @@ -4,5 +4,6 @@ export const LOCKS = { RECEIVE: 'RECEIVE_LOCKS', SET_ITEM_AS_LOCKED: 'SET_ITEM_AS_LOCKED', SET_ITEM_AS_UNLOCKED: 'SET_ITEM_AS_UNLOCKED', + RELOAD_SOFT_LOCKS_FOR_RELATED_EVENTS: 'RELOAD_SOFT_LOCKS_FOR_RELATED_EVENTS', }, }; diff --git a/client/globals.d.ts b/client/globals.d.ts index 2dfe38f1e..04e2bb3a8 100644 --- a/client/globals.d.ts +++ b/client/globals.d.ts @@ -1,3 +1,8 @@ +type IPlanningItem = import('./interfaces').IPlanningItem; +type IEventItem = import('./interfaces').IEventItem; +type PLANNING_VIEW = import('./interfaces').PLANNING_VIEW; + + // ------------------------------------------------------------------------------------------------ // VARIABLES // ------------------------------------------------------------------------------------------------ @@ -147,3 +152,44 @@ declare var ResizeObserverSize: { interface ResizeObserverCallback { (entries: ResizeObserverEntry[], observer: ResizeObserver): void; } + +// KEEP IN SYNC WITH client/planning-extension/src/globals.d.ts +declare module 'superdesk-api' { + interface ISuperdeskGlobalConfig { + event_templates_enabled?: boolean; + long_event_duration_threshold?: number; + max_multi_day_event_duration?: number; + max_recurrent_events?: number; + planning_allow_freetext_location: boolean; + planning_allow_scheduled_updates?: boolean; + planning_auto_assign_to_workflow?: boolean; + planning_check_for_assignment_on_publish?: boolean; + planning_check_for_assignment_on_send?: boolean; + planning_fulfil_on_publish_for_desks: Array; + planning_link_updates_to_coverage?: boolean; + planning_use_xmp_for_pic_assignments?: boolean; + planning_use_xmp_for_pic_slugline?: boolean; + planning_xmp_assignment_mapping?: string; + + // see: PLANNING_EVENT_LINK_METHOD + planning_event_link_method: 'one_primary' | 'many_secondary' | 'one_primary_many_secondary'; + + street_map_url?: string; + planning_auto_close_popup_editor?: boolean; + start_of_week?: number; + planning_default_view: PLANNING_VIEW; + + planning?: { + dateformat?: string; + timeformat?: string; + allowed_coverage_link_types?: Array; + autosave_timeout?: number; + default_create_planning_series_with_event_series?: boolean; + event_related_item_search_provider_name?: string; + }; + + coverage?: { + getDueDateStrategy?(planningItem: IPlanningItem, eventItem?: IEventItem): moment.Moment | null; + }; + } +} \ No newline at end of file diff --git a/client/interfaces.ts b/client/interfaces.ts index 78e5b26cd..ba1378e20 100644 --- a/client/interfaces.ts +++ b/client/interfaces.ts @@ -1,5 +1,4 @@ import { - ISuperdeskGlobalConfig, IBaseRestApiResponse, ISubject, IUser, @@ -270,40 +269,6 @@ export type IPlace = { rel: string; }; -export interface IPlanningConfig extends ISuperdeskGlobalConfig { - event_templates_enabled?: boolean; - long_event_duration_threshold?: number; - max_multi_day_event_duration?: number; - max_recurrent_events?: number; - planning_allow_freetext_location: boolean; - planning_allow_scheduled_updates?: boolean; - planning_auto_assign_to_workflow?: boolean; - planning_check_for_assignment_on_publish?: boolean; - planning_check_for_assignment_on_send?: boolean; - planning_fulfil_on_publish_for_desks: Array; - planning_link_updates_to_coverage?: boolean; - planning_use_xmp_for_pic_assignments?: boolean; - planning_use_xmp_for_pic_slugline?: boolean; - planning_xmp_assignment_mapping?: string; - street_map_url?: string; - planning_auto_close_popup_editor?: boolean; - start_of_week?: number; - planning_default_view: PLANNING_VIEW; - - planning?: { - dateformat?: string; - timeformat?: string; - allowed_coverage_link_types?: Array; - autosave_timeout?: number; - default_create_planning_series_with_event_series?: boolean; - event_related_item_search_provider_name?: string; - }; - - coverage?: { - getDueDateStrategy?(planningItem: IPlanningItem, eventItem?: IEventItem): moment.Moment | null; - }; -} - export interface ISession { sessionId: string; identity: IUser; @@ -779,6 +744,16 @@ export interface IPlanningItem extends IBaseRestApiResponse { // Used when showing Associated Planning item for Events _agendas: Array; + /** + * This is for storing UI related data that is not a part of the planning item entity itself, + * but is required to be persisted to complete a multi-step workflow. + * It will be persisted in /planning_autosave, but not in /planning endpoint + */ + _temporary?: { + // is used when linking planning items to an event + link_type?: IPlanningRelatedEventLinkType; + } + // Attributes added by API (removed via modifyForClient) // The `_status` field is available when the item comes from a POST/PATCH request _status: any; @@ -2060,7 +2035,7 @@ export interface IEditorFormGroup { } export abstract class IEditorRefComponent { - abstract scrollIntoView(): void; + abstract scrollIntoView(options?: {focus?: boolean}): void; abstract getBoundingClientRect(): DOMRect | undefined; abstract focus(): void; } @@ -2105,7 +2080,15 @@ export interface IWebsocketMessageData { removed_agendas: Array; session: ISession['sessionId']; event_ids: Array; + related_events_changed?: boolean; }; + EVENT_LINK_UPDATED: { + action: string; + event: IEventItem['_id']; + planning: IPlanningItem['_id']; + links: Array; + _created: string; // ISO 8601 datetime + } } export interface IEditorAPI { @@ -2147,7 +2130,7 @@ export interface IEditorAPI { ): Promise; scrollToTop(): void; - scrollToBookmarkGroup(bookmarkId: IEditorBookmarkGroup['group_id']): void; + scrollToBookmarkGroup(bookmarkId: IEditorBookmarkGroup['group_id'], options?: {focus?: boolean}): void; waitForScroll(): Promise; getAction(): IEditorAction; @@ -2164,6 +2147,7 @@ export interface IEditorAPI { item: { getItemType(): string; getItemId(): IEventOrPlanningItem['_id']; + getItemAction(): IEditorProps['itemAction']; getAssociatedPlannings(): Array; events: { getGroupsForItem(item: Partial): { @@ -2171,7 +2155,12 @@ export interface IEditorAPI { groups: Array; }; getRelatedPlanningDomRef(planId: IPlanningItem['_id']): React.RefObject; - addPlanningItem(): void; + addPlanningItem( + item?: IPlanningItem, + options?: { + scrollIntoViewAndFocus?: boolean; + }, + ): Promise>; removePlanningItem(item: DeepPartial): void; updatePlanningItem( original: DeepPartial, @@ -2210,6 +2199,7 @@ export interface IPlanningAPI { getSearchProfile(): IEventSearchProfile; create(updates: Partial): Promise>; update(original: IEventItem, updates: Partial): Promise>; + getLinkedPlanningItems(eventId: string): Promise>; }; planning: { search(params: ISearchParams): Promise>; @@ -2332,6 +2322,7 @@ export interface IPlanningAPI { loadLockedItems(types?: Array<'events_and_planning' | 'featured_planning' | 'assignments'>): Promise; setItemAsLocked(data: IWebsocketMessageData['ITEM_LOCKED']): void; setItemAsUnlocked(data: IWebsocketMessageData['ITEM_UNLOCKED']): void; + reloadSoftLocksForRelatedEvents(planning: IPlanningItem): void; lockItem(item: T, action: string): Promise; lockItemById( itemId: T['_id'], diff --git a/client/planning-extension/src/extension.ts b/client/planning-extension/src/extension.ts index 6a723a912..5fa58e1ab 100644 --- a/client/planning-extension/src/extension.ts +++ b/client/planning-extension/src/extension.ts @@ -8,7 +8,6 @@ import { IAuthoringAction, } from 'superdesk-api'; import {IPlanningAssignmentService} from './interfaces'; -import {IPlanningConfig} from '../../interfaces'; import {getAssignmentService} from './utils'; import {AssignmentsList} from './assignments-overview'; import {IPlanningExtensionConfigurationOptions} from './extension_configuration_options'; @@ -60,7 +59,7 @@ function onPublishArticle(superdesk: ISuperdesk, item: IArticle): Promise, desk: IDesk return Promise.resolve(); } - const config: IPlanningConfig = superdesk.instance.config as IPlanningConfig; + const {config} = superdesk.instance; if (!config || !config.planning_check_for_assignment_on_send) { return Promise.resolve(); diff --git a/client/planning-extension/src/globals.d.ts b/client/planning-extension/src/globals.d.ts index 21cfc6225..32cb39e7e 100644 --- a/client/planning-extension/src/globals.d.ts +++ b/client/planning-extension/src/globals.d.ts @@ -4,3 +4,44 @@ declare const angular: IAngularStatic; type DeepPartial = { [K in keyof T]?: DeepPartial; } + +// KEEP IN SYNC WITH client/globals.d.ts +declare module 'superdesk-api' { + interface ISuperdeskGlobalConfig { + event_templates_enabled?: boolean; + long_event_duration_threshold?: number; + max_multi_day_event_duration?: number; + max_recurrent_events?: number; + planning_allow_freetext_location: boolean; + planning_allow_scheduled_updates?: boolean; + planning_auto_assign_to_workflow?: boolean; + planning_check_for_assignment_on_publish?: boolean; + planning_check_for_assignment_on_send?: boolean; + planning_fulfil_on_publish_for_desks: Array; + planning_link_updates_to_coverage?: boolean; + planning_use_xmp_for_pic_assignments?: boolean; + planning_use_xmp_for_pic_slugline?: boolean; + planning_xmp_assignment_mapping?: string; + + // see: PLANNING_EVENT_LINK_METHOD + planning_event_link_method: 'one_primary' | 'many_secondary' | 'one_primary_many_secondary'; + + street_map_url?: string; + planning_auto_close_popup_editor?: boolean; + start_of_week?: number; + planning_default_view: PLANNING_VIEW; + + planning?: { + dateformat?: string; + timeformat?: string; + allowed_coverage_link_types?: Array; + autosave_timeout?: number; + default_create_planning_series_with_event_series?: boolean; + event_related_item_search_provider_name?: string; + }; + + coverage?: { + getDueDateStrategy?(planningItem: IPlanningItem, eventItem?: IEventItem): moment.Moment | null; + }; + } +} \ No newline at end of file diff --git a/client/reducers/events.ts b/client/reducers/events.ts index c737bb40f..0ad5f7bcd 100644 --- a/client/reducers/events.ts +++ b/client/reducers/events.ts @@ -157,20 +157,14 @@ const eventsReducer = createReducer(initialState, { }; }, - [EVENTS.ACTIONS.MARK_EVENT_HAS_PLANNINGS]: (state, payload) => { + [EVENTS.ACTIONS.SET_EVENT_PLANNINGS]: (state, payload) => { // If the event is not loaded, disregard this action if (!(payload.event_id in state.events)) return state; let events = cloneDeep(state.events); let event = events[payload.event_id]; - const planningIds = get(event, 'planning_ids', []); - - if (planningIds.includes(payload.planning_item) !== true) { - planningIds.push(payload.planning_item); - } - - event.planning_ids = planningIds; + event.planning_ids = payload.planning_ids; return { ...state, diff --git a/client/reducers/locks.ts b/client/reducers/locks.ts index d52baf3ef..e2206c43d 100644 --- a/client/reducers/locks.ts +++ b/client/reducers/locks.ts @@ -1,7 +1,8 @@ import {ILockedItems, ILock, IWebsocketMessageData} from '../interfaces'; import {createReducer} from './createReducer'; import {RESET_STORE, INIT_STORE, LOCKS} from '../constants'; -import {cloneDeep, get} from 'lodash'; +import {cloneDeep} from 'lodash'; +import {getRelatedEventIdsForPlanning} from '../utils/planning'; const initialLockState: ILockedItems = { event: {}, @@ -40,6 +41,8 @@ function addLock(state: ILockedItems, data: IWebsocketMessageData['ITEM_LOCKED'] if (data.recurrence_id != null) { state.recurring[data.recurrence_id] = lockData; } else if ((data.event_ids?.length ?? 0) > 0) { + state[data.type][data.item] = lockData; + // For now, only support 1 primary event link for locks state.event[data.event_ids[0]] = lockData; } else { @@ -70,4 +73,31 @@ export default createReducer(initialLockState, { [LOCKS.ACTIONS.SET_ITEM_AS_UNLOCKED]: (state: ILockedItems, payload: IWebsocketMessageData['ITEM_UNLOCKED']) => ( removeLock(cloneDeep(state), payload) ), + + [LOCKS.ACTIONS.RELOAD_SOFT_LOCKS_FOR_RELATED_EVENTS]: (state: ILockedItems, payload: {planning: IPlanningItem}) => { + const nextEventLocks = {...state.event}; + const {planning} = payload; + + for (const [eventId, lockObject] of Object.entries(nextEventLocks)) { + if (lockObject.item_id === planning._id) { + delete nextEventLocks[eventId]; + } + } + + for (const relatedEventId of getRelatedEventIdsForPlanning(planning, 'primary')) { + nextEventLocks[relatedEventId] = { + action: planning.lock_action, + item_id: planning._id, + item_type: 'planning', + session: planning.lock_session, + time: planning.lock_time, + user: planning.lock_user, + }; + } + + return { + ...state, + event: nextEventLocks, + }; + }, }); diff --git a/client/reducers/tests/locks_test.ts b/client/reducers/tests/locks_test.ts index 7fbb5d6f9..89635f474 100644 --- a/client/reducers/tests/locks_test.ts +++ b/client/reducers/tests/locks_test.ts @@ -87,19 +87,6 @@ describe('lock reducers', () => { assignment: {a1: lockUtils.getLockFromItem(lockTypes.assignment)}, }; - const initialLocks = { - events: [ - lockTypes.events.event, - lockTypes.events.recurring, - ], - plans: [ - lockTypes.planning.planning, - lockTypes.planning.event, - lockTypes.planning.recurring, - ], - assignments: [lockTypes.assignment], - }; - const getInitialLocks = () => (locks( initialState, { @@ -166,7 +153,9 @@ describe('lock reducers', () => { ); expect(result).toEqual({ event: {e3: lockItems.event.e3}, - planning: {}, + planning: { + p2: lockUtils.getLockFromItem(lockTypes.planning.event), + }, recurring: {}, assignment: {}, }); diff --git a/client/selectors/forms.ts b/client/selectors/forms.ts index b391def72..ed2b01554 100644 --- a/client/selectors/forms.ts +++ b/client/selectors/forms.ts @@ -6,6 +6,7 @@ import {appConfig} from 'appConfig'; import {ITEM_TYPE, MAIN} from '../constants'; import {sessionId as getSessionId} from './general'; import {isExistingItem} from '../utils'; +import {EDITOR_TYPE} from '../interfaces'; // Helper function const getcurrentItem = (itemId, itemType, events, plannings, values, modal = false) => { @@ -131,6 +132,16 @@ export const currentItem = createSelector( /** Forms - Modal Editor */ + +export const currentEditorType = (state): EDITOR_TYPE | null => { + if (state?.forms?.editors?.modal?.itemId != null) { + return EDITOR_TYPE.POPUP; + } else if (state?.forms?.editors?.panel?.itemId != null) { + return EDITOR_TYPE.INLINE; + } else { + return null; + } +}; export const currentItemIdModal = (state) => get(state, 'forms.editors.modal.itemId', null); export const currentItemTypeModal = (state) => get(state, 'forms.editors.modal.itemType', null); export const currentItemActionModal = (state) => get(state, 'forms.editors.modal.action', null); diff --git a/client/selectors/main.ts b/client/selectors/main.ts index 0ca769869..66459346c 100644 --- a/client/selectors/main.ts +++ b/client/selectors/main.ts @@ -17,7 +17,6 @@ import {currentEventFilterId, eventsInList, orderedEvents, storedEvents} from '. import {currentPlanningFilterId, orderedPlanningList, plansInList, storedPlannings} from './planning'; import {getEventsPlanningList, orderedEventsPlanning, selectedFilter} from './eventsplanning'; import {getSearchDateRange} from '../utils'; -import {planningConfig} from '../config'; export const getCurrentListViewType = (state?: IPlanningAppState) => ( @@ -25,7 +24,7 @@ export const getCurrentListViewType = (state?: IPlanningAppState) => ( ); export const activeFilter = (state: IPlanningAppState) => { const privileges = get(state, 'privileges', ''); - const defaultView = planningConfig.planning_default_view; + const defaultView = appConfig.planning_default_view; if (privileges?.planning_event_management && privileges?.planning_planning_management) { return state?.main?.filter ?? defaultView; diff --git a/client/selectors/tests/main_test.ts b/client/selectors/tests/main_test.ts index 7df34739a..576413bdb 100644 --- a/client/selectors/tests/main_test.ts +++ b/client/selectors/tests/main_test.ts @@ -1,7 +1,7 @@ import * as selectors from '../index'; import moment from 'moment'; import {MAIN, SPIKED_STATE} from '../../constants'; -import {planningConfig} from '../../config'; +import {appConfig} from 'appConfig'; import {PLANNING_VIEW} from '../../interfaces'; describe('main selectors', () => { @@ -104,14 +104,14 @@ describe('main selectors', () => { describe('activeFilter', () => { afterEach(() => { - planningConfig.planning_default_view = PLANNING_VIEW.COMBINED; + appConfig.planning_default_view = PLANNING_VIEW.COMBINED; }); it('reads default from app config', () => { - planningConfig.planning_default_view = PLANNING_VIEW.PLANNING; + appConfig.planning_default_view = PLANNING_VIEW.PLANNING; expect(selectors.main.activeFilter(state)).toBe(PLANNING_VIEW.PLANNING); - planningConfig.planning_default_view = PLANNING_VIEW.EVENTS; + appConfig.planning_default_view = PLANNING_VIEW.EVENTS; expect(selectors.main.activeFilter(state)).toBe(PLANNING_VIEW.EVENTS); }); }); diff --git a/client/utils/archive.ts b/client/utils/archive.ts index ea78dba55..a172b937c 100644 --- a/client/utils/archive.ts +++ b/client/utils/archive.ts @@ -1,9 +1,8 @@ import {IArticle} from 'superdesk-api'; -import {IPlanningConfig} from '../interfaces'; import {appConfig} from 'appConfig'; export function isContentLinkToCoverageAllowed(item: IArticle) { - const config = appConfig as IPlanningConfig; + const config = appConfig; return !config?.planning?.allowed_coverage_link_types?.length ? true : diff --git a/client/utils/confirmAddingRelatedItems.tsx b/client/utils/confirmAddingRelatedItems.tsx new file mode 100644 index 000000000..f1c4bfaff --- /dev/null +++ b/client/utils/confirmAddingRelatedItems.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import {Spacer, Modal, Button} from 'superdesk-ui-framework/react'; +import {superdeskApi} from '../superdeskApi'; + +export function confirmAddingRelatedItems( + warnings: Array, + attemptedToAdd: number, + canBeAdded: number, +): Promise { + return new Promise((resolve, reject) => { + const {gettextPlural, gettext} = superdeskApi.localization; + + superdeskApi.ui.showModal((options) => { + const closeAndReject = () => { + options.closeModal(); + + reject(); + }; + + const issuesJSX = ( + +

{gettext('Issues detected:')}

+ +
    + {warnings.map((warning, i) => ( +
  • {warning}
  • + ))} +
+
+ ); + + if (canBeAdded < 1) { + return ( + closeAndReject()} /> + )} + zIndex={1050} + > + {issuesJSX} + + ); + } else { + return ( + +