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/.github/workflows/ci-client.yml b/.github/workflows/ci-client.yml index 110f7f2d0..9671e14c7 100644 --- a/.github/workflows/ci-client.yml +++ b/.github/workflows/ci-client.yml @@ -19,3 +19,22 @@ jobs: - run: npm ci - run: npm run test - run: npm run lint + + extension: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [14.x] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + - name: install + working-directory: client/planning-extension + run: npm ci + - name: compile + working-directory: client/planning-extension + run: npm run compile diff --git a/README.md b/README.md index 0c12b9e8e..4940dfebf 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,9 @@ Below sections include the config options that can be defined in settings.py. * PLANNING_JSON_ASSIGNED_INFO_EXTENDED * Defaults to `false` * If `true`, it will add to planning JSON output additional info for coverages like assigned desk name/email and assigned user name/email. +* ASSIGNMENT_MANUAL_REASSIGNMENT_ONLY + * Default: False (preserves the current behavior where automatic user assignment occurs) + * If true, Disables automatic user assignment for coverage, ensuring that assignments are updated only through explicit manual reassignment ### Authoring Config * PLANNING_CHECK_FOR_ASSIGNMENT_ON_PUBLISH 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/apps/Planning/PlanningListSubNav.tsx b/client/apps/Planning/PlanningListSubNav.tsx index 479cf9252..e14ed57bb 100644 --- a/client/apps/Planning/PlanningListSubNav.tsx +++ b/client/apps/Planning/PlanningListSubNav.tsx @@ -183,7 +183,7 @@ class PlanningListSubNavComponent extends React.Component { return (
- + diff --git a/client/apps/Planning/PlanningSubNav.tsx b/client/apps/Planning/PlanningSubNav.tsx index 46a816ecc..ae609efd6 100644 --- a/client/apps/Planning/PlanningSubNav.tsx +++ b/client/apps/Planning/PlanningSubNav.tsx @@ -86,7 +86,7 @@ export class PlanningSubNavComponent extends React.PureComponent { {this.props.withArchiveItem !== true ? null : ( )} - + { privileges={this.props.privileges} /> - + + {!showDeskSelection ? ( diff --git a/client/components/Assignments/SubNavBar.tsx b/client/components/Assignments/SubNavBar.tsx index b97a43acd..e70103e59 100644 --- a/client/components/Assignments/SubNavBar.tsx +++ b/client/components/Assignments/SubNavBar.tsx @@ -44,7 +44,7 @@ export class SubNavBar extends React.PureComponent { const {gettext} = superdeskApi.localization; return ( - + {assignmentListSingleGroupView && ( { '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.cancel_plan_with_event': {enabled: this.props.item.name === 'related_plannings'}, 'schema.field_type': {enabled: fieldType != null}, 'schema.minlength': {enabled: !disableMinMax}, 'schema.maxlength': {enabled: !disableMinMax}, @@ -190,6 +191,7 @@ export class FieldEditor extends React.Component { 'schema.languages': {enabled: true, index: 12}, 'schema.default_language': {enabled: true, index: 13}, 'schema.planning_auto_publish': {enabled: true, index: 14}, + 'schema.cancel_plan_with_event': {enabled: true, index: 14}, 'schema.default_value': {enabled: true, index: 11}, }, { diff --git a/client/components/Coverages/CoverageIcons.tsx b/client/components/Coverages/CoverageIcons.tsx index 222465275..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 { @@ -97,7 +95,6 @@ export class CoverageIcons extends React.PureComponent { return ( (
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 0d64a688c..e984e5ea9 100644 --- a/client/components/Events/EventItem.tsx +++ b/client/components/Events/EventItem.tsx @@ -118,7 +118,7 @@ class EventItemComponent extends React.Component { return (
- + { (toggle) => (
{ 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 975b81703..dee860c9b 100644 --- a/client/components/Planning/PlanningItem.tsx +++ b/client/components/Planning/PlanningItem.tsx @@ -144,7 +144,7 @@ class PlanningItemComponent extends React.Component { return (
- + { (toggle) => (
{ 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'; + }} > cal1.label.localeCompare(cal2.label))} > {dropdownLabel} diff --git a/client/components/UI/List/Item.tsx b/client/components/UI/List/Item.tsx index 9eaa2852c..a6fc69a95 100644 --- a/client/components/UI/List/Item.tsx +++ b/client/components/UI/List/Item.tsx @@ -21,6 +21,7 @@ interface IProps { onMouseUp?(event: React.MouseEvent): 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/CustomVocabularies.tsx b/client/components/fields/editor/CustomVocabularies.tsx index 927dae8b8..27eedb00a 100644 --- a/client/components/fields/editor/CustomVocabularies.tsx +++ b/client/components/fields/editor/CustomVocabularies.tsx @@ -86,7 +86,6 @@ class CustomVocabulariesComponent extends React.PureComponent { ); }} tabindex={0} - zIndex={1051} /> ); diff --git a/client/components/fields/editor/EventRelatedArticles/EventsRelatedArticlesModal.tsx b/client/components/fields/editor/EventRelatedArticles/EventsRelatedArticlesModal.tsx index 2f1fc86c7..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>; @@ -162,8 +159,6 @@ export class EventsRelatedArticlesModal extends React.Component > ; 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/components/fields/editor/base/numberSelect.tsx b/client/components/fields/editor/base/numberSelect.tsx index b6fb78f70..8a876378d 100644 --- a/client/components/fields/editor/base/numberSelect.tsx +++ b/client/components/fields/editor/base/numberSelect.tsx @@ -103,7 +103,6 @@ export class EditorFieldNumberSelect extends React.PureComponent { value={values} onChange={this.onChangeMultiple} allowMultiple={true} - zIndex={1051} /> ); } diff --git a/client/components/fields/editor/base/treeSelect.tsx b/client/components/fields/editor/base/treeSelect.tsx index 70095b663..92390cbf3 100644 --- a/client/components/fields/editor/base/treeSelect.tsx +++ b/client/components/fields/editor/base/treeSelect.tsx @@ -88,7 +88,6 @@ export class EditorFieldTreeSelect extends React.PureComponent diff --git a/client/components/fields/resources/profiles.ts b/client/components/fields/resources/profiles.ts index 32aaa2397..6b12e1ee2 100644 --- a/client/components/fields/resources/profiles.ts +++ b/client/components/fields/resources/profiles.ts @@ -35,6 +35,17 @@ registerEditorField( true ); +registerEditorField( + 'schema.cancel_plan_with_event', + EditorFieldCheckbox, + () => ({ + label: superdeskApi.localization.gettext('Cancel planning items with Event'), + field: 'schema.cancel_plan_with_event', + }), + null, + true +); + registerEditorField( 'schema.planning_auto_publish', EditorFieldCheckbox, 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 9e3394e0b..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; @@ -1069,6 +1044,7 @@ export interface IProfileSchemaTypeList extends IBaseProfileSchemaType<'list'> { mandatory_in_list?: {[key: string]: any}; vocabularies?: Array; planning_auto_publish?: boolean; + cancel_plan_with_event?: boolean; } export interface IProfileSchemaTypeInteger extends IBaseProfileSchemaType<'integer'> {} @@ -1171,6 +1147,7 @@ export interface IEventFormProfile { reference: IProfileEditorField; slugline: IProfileEditorField; subject: IProfileEditorField; + related_plannings: IProfileEditorField; }; name: 'event'; schema: { @@ -1194,6 +1171,7 @@ export interface IEventFormProfile { reference: IProfileSchemaTypeString; slugline: IProfileSchemaTypeString; subject: IProfileSchemaTypeList; + related_plannings: IProfileSchemaTypeList; }; } @@ -2057,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; } @@ -2102,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 { @@ -2144,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; @@ -2161,6 +2147,7 @@ export interface IEditorAPI { item: { getItemType(): string; getItemId(): IEventOrPlanningItem['_id']; + getItemAction(): IEditorProps['itemAction']; getAssociatedPlannings(): Array; events: { getGroupsForItem(item: Partial): { @@ -2168,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, @@ -2207,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>; @@ -2329,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/assignments-overview/hiddenAssignmentsList.tsx b/client/planning-extension/src/assignments-overview/hiddenAssignmentsList.tsx index 1b4bf6b86..66d68ed8a 100644 --- a/client/planning-extension/src/assignments-overview/hiddenAssignmentsList.tsx +++ b/client/planning-extension/src/assignments-overview/hiddenAssignmentsList.tsx @@ -49,7 +49,7 @@ export class AssignmentsCountTracker extends React.PureComponent<{}, {loading: t {menuId: 'MENU_ITEM_PLANNING_ASSIGNMENTS', badgeValue: itemsCount.toString()}, ); - return null; + return <>; } } 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/styles/index.scss b/client/styles/index.scss index 3d03fffb7..f74f005d8 100644 --- a/client/styles/index.scss +++ b/client/styles/index.scss @@ -108,13 +108,6 @@ justify-content: flex-end; } -// Fix second subnav's z-index in Planning page -#sd-planning-react-container { - .subnav + .subnav { - z-index: 1002 !important; - } -} - // Fix react-bootstrap OverlayTrigger's Tooltip inside Modals .tooltip { z-index: 10000 !important; 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 ( + +