diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..3d9999646 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "npm" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..0cadae33b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,13 @@ +## Front-end checklist + + +- [ ] This pull request is adding missing TypeScript types to modified code segments where it's easy to do so with confidence +- [ ] This pull request is using TypeScript interfaces instead of prop-types and updates usages where it's quick to do so +- [ ] This pull request is using `memo` or `PureComponent` to define new React components (and updates existing usages in modified code segments) +- [ ] This pull request is replacing `lodash.get` with optional chaining and nullish coalescing for modified code segments +- [ ] This pull request is not importing anything from client-core directly (use `superdeskApi`) +- [ ] This pull request is importing UI components from `superdesk-ui-framework` and `superdeskApi` when possible instead of using ones defined in this repository. +- [ ] This pull request is not using `planningApi` where it is possible to use `superdeskApi` +- [ ] This pull request is not adding redux based modals +- [ ] In this pull request, properties of redux state are not being passed as props to components; instead, we connect it to the component that needs them. Except components where using a react key is required - do not connect those due to performance reasons. +- [ ] This pull request is not adding redux actions that do not modify state (e.g. only calling angular services; those should be moved to `planningApi`) diff --git a/.github/workflows/ci-client.yml b/.github/workflows/ci-client.yml index da6ced731..110f7f2d0 100644 --- a/.github/workflows/ci-client.yml +++ b/.github/workflows/ci-client.yml @@ -8,19 +8,14 @@ jobs: strategy: matrix: node-version: [14.x] - env: - INSTALL_NODE_MODULES: true steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - - name: Setup Environment - run: ./scripts/ci-install.sh - - name: Start Services - run: ./scripts/ci-start-services.sh + cache: npm # avoid file watch limit error - run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p - - run: npm list || true - - name: test - run: npm run test + - run: npm ci + - run: npm run test + - run: npm run lint diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index 297e4ad2a..000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,55 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -name: "CodeQL" - -on: [push, pull_request] - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - # Override automatic language detection by changing the below list - # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] - language: ['python'] - # Learn more... - # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # ℹī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 85968c945..000000000 --- a/.travis.yml +++ /dev/null @@ -1,94 +0,0 @@ -language: python - -python: 3.6 - -dist: bionic - -services: - - docker - -addons: - chrome: stable - -cache: - pip: true - npm: true - directories: - - ~/.npm - - ~/.cache - -before_install: - - | - if [ "$INSTALL_NODEJS" == "true" ]; then - nvm install lts/* - fi - - | - if [ "$INSTALL_NODE_MODULES" == "true" ]; then - npm install - fi - - | - if [ "$INSTALL_PY_MODULES" == "true" ]; then - cd server - pip install -r requirements.txt - cd .. - fi - - | - if [ "$RUN_SERVICES" == "true" ]; then - docker-compose -f .travis-docker-compose.yml up -d - sleep 30 - fi - - | - if [ "$E2E" == "true" ]; then - cd e2e/server - pip install -r requirements.txt - cd .. - fi - - | - if [ "$E2E" == "true" ]; then - npm install - fi - - | - if [ "$E2E" == "true" ]; then - npm run build - fi - - | - if [ "$E2E" == "true" ]; then - cd server - honcho start & - cd .. - fi - -jobs: - include: - - name: "server" - env: - - INSTALL_NODEJS=false - - INSTALL_NODE_MODULES=false - - INSTALL_PY_MODULES=true - - RUN_SERVICES=true - script: cd server && flake8 && nosetests --logging-level=ERROR && behave --format progress2 --logging-level=ERROR - - name: "client" - env: - - INSTALL_NODEJS=true - - INSTALL_NODE_MODULES=true - - INSTALL_PY_MODULES=false - - RUN_SERVICES=false - script: npm run test - - name: "e2e vol. 1 (events)" - env: - - INSTALL_NODEJS=true - - INSTALL_NODE_MODULES=true - - INSTALL_PY_MODULES=false - - RUN_SERVICES=true - - E2E=true - - TZ=Australia/Sydney - script: npm run cypress-ci -- --spec "cypress/integration/events/*.spec.js" - - name: "e2e vol. 2 (non-events)" - env: - - INSTALL_NODEJS=true - - INSTALL_NODE_MODULES=true - - INSTALL_PY_MODULES=false - - RUN_SERVICES=true - - E2E=true - - TZ=Australia/Sydney - script: npm run cypress-ci -- --spec "cypress/integration/!(events)/*.spec.js" diff --git a/README.md b/README.md index 5667606e5..8aa912cc7 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,10 @@ Below sections include the config options that can be defined in settings.py. * EVENT_EXPORT_BODY_TEMPLATE: * default: https://github.com/superdesk/superdesk-planning/blob/develop/server/planning/planning_export_templates.py#L39 * Overrides the default event template used for event exports +* EVENT_RELATED_ITEM_SEARCH_PROVIDER_NAME: + * Default: None + * Required By: Event field ``related_items`` (otherwise this field will be automatically disabled) + * Defines the name of the Search Provider to use for adding related items to an Event ### Planning Config * LONG_EVENT_DURATION_THRESHOLD: @@ -165,6 +169,24 @@ Below sections include the config options that can be defined in settings.py. * PLANNING_DEFAULT_COVERAGE_STATUS_ON_INGEST: * Default: 'ncostat:int' - Coverage Planned * The default CV qcode for populating planning.coverage[x].news_coverage_status on ingest +* DEFAULT_CREATE_PLANNING_SERIES_WITH_EVENT_SERIES + * Default: False + * If true, will default to creating series of Planning items with a recurring series of Events, +* SYNC_EVENT_FIELDS_TO_PLANNING + * Default: "" + * Comma separated list of Planning & Coverage fields to keep in sync with the associated Event + * Supported Fields: + * slugline + * internal_note + * name + * place (list CVs) + * subject (list CVs, exclude items with scheme) + * custom_vocabularies (list CVs, inside subject with scheme) + * anpa_category (list CVs) + * ednote + * language (includes `languages` if multilingual is enabled) + * definition_short (copies to Planning item's `Description Text`) + * priority ### Assignments Config * SLACK_BOT_TOKEN @@ -176,6 +198,9 @@ Below sections include the config options that can be defined in settings.py. * PLANNING_SEND_NOTIFICATION_FOR_SELF_ASSIGNMENT * Defaults to false * If true, sends a notification to a user on creating an assignment that is assigned to themselves +* 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. ### Authoring Config * PLANNING_CHECK_FOR_ASSIGNMENT_ON_PUBLISH diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..af54f990f --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,4 @@ +# Long term refactoring goals + +* Replace current event and planning item editor with react-based authoring component from superdesk (see `getAuthoringComponent` in `superdeskApi`) +* Refactor `EventItem` and `PlanningItem` components so they do not connect to a redux store \ No newline at end of file diff --git a/client/actions/assignments/ui.ts b/client/actions/assignments/ui.ts index 02022aea7..d2a5ed2e6 100644 --- a/client/actions/assignments/ui.ts +++ b/client/actions/assignments/ui.ts @@ -1,7 +1,7 @@ import {get, cloneDeep, forEach} from 'lodash'; import moment from 'moment'; -import {planningApi} from '../../superdeskApi'; +import {planningApi, superdeskApi} from '../../superdeskApi'; import {IAssignmentItem} from '../../interfaces'; import {showModal} from '../index'; @@ -24,6 +24,7 @@ const loadAssignments = ({ filterBy = 'Desk', searchQuery = null, orderByField = 'Scheduled', + dayField = null, filterByType = null, filterByPriority = null, selectedDeskId = null, @@ -34,6 +35,7 @@ const loadAssignments = ({ filterBy, searchQuery, orderByField, + dayField, filterByType, filterByPriority, selectedDeskId, @@ -304,6 +306,7 @@ const changeListSettings = ({ filterBy = 'Desk', searchQuery = null, orderByField = 'Scheduled', + dayField = null, filterByType = null, filterByPriority = null, selectedDeskId = null, @@ -314,6 +317,7 @@ const changeListSettings = ({ filterBy, searchQuery, orderByField, + dayField, filterByType, filterByPriority, selectedDeskId, @@ -628,12 +632,10 @@ function startWorking(assignment: IAssignmentItem) { promise.then(() => (planningApi.locks.lockItem(assignment, 'start_working') .then((lockedAssignment) => { - const currentDesk = assignmentUtils.getCurrentSelectedDesk(desks, getState()); - const defaultTemplateId = get(currentDesk, 'default_content_template') || null; + const defaultTemplateId = assignmentUtils + .getCurrentSelectedDesk(desks, getState())?.default_content_template ?? null; - return templates.fetchTemplatesByUserDesk( - session.identity._id, - get(currentDesk, '_id') || null, + return superdeskApi.entities.templates.getUserTemplates( 1, 200, 'create' @@ -807,6 +809,27 @@ const changeSortField = (field, savePreference = true) => ( } ); +/** + * Action dispatcher to set the day field filter for all lists + * @param {String} value - the value to set the field to + */ +const setDayField = (value) => ({ + type: ASSIGNMENTS.ACTIONS.SET_DAY_FIELD, + payload: value, +}); + +const changeDayField = (value, savePreference = true) => ( + (dispatch) => { + dispatch(self.setDayField(value)); + + // if (savePreference) { + // dispatch(actions.users.setAssignmentSortField()); + // } + + return dispatch(self.reloadAssignments(null, false)); + } +); + /** * Action dispatcher to load the current users' preferred sort field and list orders * (This assumes the users' preferences have already been loaded into redux) @@ -899,6 +922,8 @@ const self = { setListSortOrder, changeListSortOrder, setSortField, + setDayField, + changeDayField, loadDefaultListSort, changeSortField, validateStartWorkingOnScheduledUpdate, diff --git a/client/actions/events/api.ts b/client/actions/events/api.ts index 24cf37a19..0d5961acc 100644 --- a/client/actions/events/api.ts +++ b/client/actions/events/api.ts @@ -1,7 +1,7 @@ -import {get, isEqual, cloneDeep, pickBy, has, find, every} from 'lodash'; +import {get, isEqual, cloneDeep, pickBy, has, find, every, take} from 'lodash'; import {planningApi} from '../../superdeskApi'; -import {ISearchSpikeState, IEventSearchParams, IEventItem, IPlanningItem} from '../../interfaces'; +import {ISearchSpikeState, IEventSearchParams, IEventItem, IPlanningItem, IEventTemplate} from '../../interfaces'; import {appConfig} from 'appConfig'; import { @@ -393,8 +393,12 @@ const post = (original, updates) => ( etag: original._etag, pubstatus: get(updates, 'pubstatus', POST_STATE.USABLE), update_method: get(updates, 'update_method.value', EVENTS.UPDATE_METHODS[0].value), + failed_planning_ids: get(updates, 'failed_planning_ids', []), }).then( - () => dispatch(self.fetchById(original._id, {force: true})), + (data) => Promise.all([ + dispatch(self.fetchById(original._id, {force: true})), + {failedPlanningIds: data?.failed_planning_ids} + ]), (error) => Promise.reject(error) ) ) @@ -558,29 +562,16 @@ const save = (original, updates) => ( return promise.then((originalEvent) => { const originalItem = eventUtils.modifyForServer(cloneDeep(originalEvent), true); - - // clone the updates as we're going to modify it - let eventUpdates = eventUtils.modifyForServer( - cloneDeep(updates), - true - ); - - originalItem.location = originalItem.location ? [originalItem.location] : null; - - // remove all properties starting with _ - // and updates that are the same as original - eventUpdates = pickBy(eventUpdates, (v, k) => ( - (k === TO_BE_CONFIRMED_FIELD || k === '_planning_item' || !k.startsWith('_')) && - !isEqual(eventUpdates[k], originalItem[k]) - )); + const eventUpdates = eventUtils.getEventDiff(originalItem, updates); if (get(originalItem, 'lock_action') === EVENTS.ITEM_ACTIONS.EDIT_EVENT.lock_action && !isTemporaryId(originalItem._id) ) { delete eventUpdates.dates; } - eventUpdates.update_method = get(eventUpdates, 'update_method.value') || - EVENTS.UPDATE_METHODS[0].value; + eventUpdates.update_method = eventUpdates.update_method == null ? + EVENTS.UPDATE_METHODS[0].value : + eventUpdates.update_method?.value ?? eventUpdates.update_method; return originalEvent?._id != null ? planningApi.events.update(originalItem, eventUpdates) : @@ -671,7 +662,7 @@ const fetchEventTemplates = () => (dispatch, getState, {api}) => { }); }; -const createEventTemplate = (itemId) => (dispatch, getState, {api, modal, notify}) => { +const createEventTemplate = (item: IEventItem) => (dispatch, getState, {api, modal, notify}) => { modal.prompt(gettext('Template name')).then((templateName) => { api('events_template').query({ where: { @@ -685,10 +676,28 @@ const createEventTemplate = (itemId) => (dispatch, getState, {api, modal, notify const doSave = () => { api('events_template').save({ template_name: templateName, - based_on_event: itemId, + based_on_event: item._id, + data: { + embedded_planning: item.associated_plannings.map((planning) => ({ + coverages: planning.coverages.map((coverage) => ({ + coverage_id: coverage.coverage_id, + g2_content_type: coverage.planning.g2_content_type, + desk: coverage.assigned_to.desk, + user: coverage.assigned_to.user, + language: coverage.planning.language, + news_coverage_status: coverage.news_coverage_status.qcode, + scheduled: coverage.planning.scheduled, + genre: coverage.planning.genre?.qcode, + slugline: coverage.planning.slugline, + ednote: coverage.planning.ednote, + internal_note: coverage.planning.internal_note, + })), + })), + }, }) .then(() => { dispatch(fetchEventTemplates()); + dispatch(getEventsRecentTemplates()); }, (error) => { notify.error( getErrorMessage(error, gettext('Failed to save the event template')) @@ -713,6 +722,29 @@ const createEventTemplate = (itemId) => (dispatch, getState, {api, modal, notify }); }; +const RECENT_EVENTS_TEMPLATES_KEY = 'events_templates:recent'; + +const addEventRecentTemplate = (field: string, templateId: IEventTemplate['_id']) => ( + (dispatch, getState, {preferencesService}) => preferencesService.get() + .then((result = {}) => { + result[RECENT_EVENTS_TEMPLATES_KEY] = result[RECENT_EVENTS_TEMPLATES_KEY] || {}; + result[RECENT_EVENTS_TEMPLATES_KEY][field] = result[RECENT_EVENTS_TEMPLATES_KEY][field] || []; + result[RECENT_EVENTS_TEMPLATES_KEY][field] = result[RECENT_EVENTS_TEMPLATES_KEY][field].filter( + (i) => i !== templateId); + result[RECENT_EVENTS_TEMPLATES_KEY][field].unshift(templateId); + return preferencesService.update(result); + }) +); + +const getEventsRecentTemplates = () => ( + (dispatch, getState, {preferencesService}) => preferencesService.get() + .then((result) => { + const templates = take(result?.[RECENT_EVENTS_TEMPLATES_KEY]?.['templates'], 5); + + dispatch({type: EVENTS.ACTIONS.EVENT_RECENT_TEMPLATES, payload: templates}); + }) +); + // eslint-disable-next-line consistent-this const self = { loadEventsByRecurrenceId, @@ -745,6 +777,8 @@ const self = { removeFile, fetchEventTemplates, createEventTemplate, + addEventRecentTemplate, + getEventsRecentTemplates, }; export default self; diff --git a/client/actions/events/notifications.ts b/client/actions/events/notifications.ts index b23bdb77e..0da3a75cd 100644 --- a/client/actions/events/notifications.ts +++ b/client/actions/events/notifications.ts @@ -366,10 +366,22 @@ const self = { }; export const planningEventTemplateEvents = { - 'events-template:created': () => eventsApi.fetchEventTemplates, - 'events-template:updated': () => eventsApi.fetchEventTemplates, - 'events-template:replaced': () => eventsApi.fetchEventTemplates, - 'events-template:deleted': () => eventsApi.fetchEventTemplates, + 'events-template:created': () => { + eventsApi.fetchEventTemplates(); + eventsApi.getEventsRecentTemplates(); + }, + 'events-template:updated': () => { + eventsApi.fetchEventTemplates(); + eventsApi.getEventsRecentTemplates(); + }, + 'events-template:replaced': () => { + eventsApi.fetchEventTemplates(); + eventsApi.getEventsRecentTemplates(); + }, + 'events-template:deleted': () => { + eventsApi.fetchEventTemplates(); + eventsApi.getEventsRecentTemplates(); + }, }; // Map of notification name and Action Event to execute diff --git a/client/actions/events/tests/api_test.ts b/client/actions/events/tests/api_test.ts index 49bee1d10..956c9dfdf 100644 --- a/client/actions/events/tests/api_test.ts +++ b/client/actions/events/tests/api_test.ts @@ -579,6 +579,7 @@ describe('actions.events.api', () => { etag: data.events[0]._etag, pubstatus: 'usable', update_method: 'single', + failed_planning_ids: [], }, ]); done(); diff --git a/client/actions/events/ui.ts b/client/actions/events/ui.ts index 2e9728337..1bf43fea4 100644 --- a/client/actions/events/ui.ts +++ b/client/actions/events/ui.ts @@ -773,6 +773,9 @@ const createEventFromPlanning = (plan: IPlanningItem) => ( if (plan.languages != null) { newEvent.languages = plan.languages; } + if (plan.priority != null) { + newEvent.priority = plan.priority; + } const fieldsToConvert: Array<[keyof IPlanningItem, keyof IEventItem]> = [ ['description_text', 'definition_short'], @@ -925,6 +928,7 @@ const save = (original, updates, confirmation, unlockOnClose) => ( { actionType: 'save', unlockOnClose: unlockOnClose, + large: true, } )); } diff --git a/client/actions/main.ts b/client/actions/main.ts index 47ee02b9d..95718be86 100644 --- a/client/actions/main.ts +++ b/client/actions/main.ts @@ -17,6 +17,7 @@ import { IWebsocketMessageData, ITEM_TYPE, IEventTemplate, + IEventItem, } from '../interfaces'; import { @@ -60,7 +61,7 @@ import { shouldUnLockItem, timeUtils } from '../utils'; -import {hideModal, locks, showModal} from './'; +import {hideModal, showModal} from './'; import {fetchSelectedAgendaPlannings} from './agenda'; import eventsPlanningUi from './eventsPlanning/ui'; @@ -176,12 +177,44 @@ const createNew = (itemType, item = null, updateUrl = true, modal = false) => ( }, 'create', updateUrl, modal) ); +function getEventsAssociatedItems(template: IEventTemplate): IEventItem['associated_plannings'] | [] { + const embeddedPlanning = template.data?.embedded_planning; + + return embeddedPlanning + ? embeddedPlanning.map((embedded) => ({ + _id: generateTempId(), + slugline: template.data?.slugline, + language: template.data?.language, + coverages: embedded.coverages.map((coverage) => ({ + coverage_id: coverage.coverage_id, + planning: { + g2_content_type: coverage.g2_content_type, + scheduled: coverage.scheduled, + language: coverage.language, + genre: coverage.genre ? {qcode: coverage.genre} : undefined, + slugline: coverage.slugline, + ednote: coverage.ednote, + internal_note: coverage.internal_note, + }, + assigned_to: { + desk: coverage.desk, + user: coverage.user, + }, + news_coverage_status: { + qcode: coverage.news_coverage_status, + }, + })), + })) + : []; +} + function createEventFromTemplate(template: IEventTemplate) { return self.createNew(ITEM_TYPE.EVENT, { ...template.data, dates: { tz: template.data.dates?.tz }, + associated_plannings: self.getEventsAssociatedItems(template) }); } @@ -419,7 +452,16 @@ const post = (original, updates = {}, withConfirmation = true) => ( return promise .then( (rtn) => { - if (!confirmation && rtn) { + let failedPlanningIds, item; + + if (Array.isArray(rtn) && rtn.length === 2) { + [item, {failedPlanningIds}] = rtn; + } else { + item = rtn; + failedPlanningIds = []; + } + + if (!confirmation && item) { notify.success( gettext( 'The {{ itemType }} has been posted', @@ -427,8 +469,12 @@ const post = (original, updates = {}, withConfirmation = true) => ( ) ); } + failedPlanningIds?.map((failedItem) => notifyError( + notify, + failedItem, + gettext('Some related planning item post validation failed'))); - return Promise.resolve(rtn); + return Promise.resolve(item); }, (error) => { notifyError( @@ -670,11 +716,7 @@ const openIgnoreCancelSaveModal = ({ const storedItems = itemType === ITEM_TYPE.EVENT ? selectors.events.storedEvents(getState()) : selectors.planning.storedPlannings(getState()); - - const item = { - ...get(storedItems, itemId) || {}, - ...autosaveData, - }; + const item = get(storedItems, itemId) || {}; if (!isExistingItem(item)) { delete item._id; @@ -703,7 +745,7 @@ const openIgnoreCancelSaveModal = ({ modalType: MODALS.IGNORE_CANCEL_SAVE, modalProps: { item: itemWithAssociatedData, - itemType: itemType, + updates: autosaveData, onCancel: onCancel, onIgnore: onIgnore, onSave: onSave, @@ -823,22 +865,15 @@ function _filter(filterType: PLANNING_VIEW, params: ICombinedEventOrPlanningSear if (currentFilterId != undefined || filterType === PLANNING_VIEW.COMBINED) { promise = planningApi.ui.list.changeFilterId(currentFilterId, params); } else if (filterType === PLANNING_VIEW.EVENTS) { - const calendar = urlParams.getString('calendar') || - lastParams?.calendars?.[0] || - (lastParams?.noCalendarAssigned ? - EVENTS.FILTER.NO_CALENDAR_ASSIGNED : - EVENTS.FILTER.ALL_CALENDARS - ); - - const calender = $location.search().calendar || + const calendar = $location.search().calendar || get(lastParams, 'calendars[0]', null) || (get(lastParams, 'noCalendarAssigned', false) ? - EVENTS.FILTER.NO_CALENDAR_ASSIGNED : - EVENTS.FILTER.ALL_CALENDARS + {qcode: EVENTS.FILTER.NO_CALENDAR_ASSIGNED} : + {qcode: EVENTS.FILTER.ALL_CALENDARS} ); promise = planningApi.ui.list.changeCalendarId( - calender, + calendar.qcode, params ); } else if (filterType === PLANNING_VIEW.PLANNING) { @@ -1670,6 +1705,7 @@ const self = { changeEditorAction, notifyPreconditionFailed, setUnsetUserInitiatedSearch, + getEventsAssociatedItems, }; export default self; diff --git a/client/actions/multiSelect.ts b/client/actions/multiSelect.ts index 834de3dd6..c07920b88 100644 --- a/client/actions/multiSelect.ts +++ b/client/actions/multiSelect.ts @@ -8,6 +8,7 @@ import {MULTISELECT, ITEM_TYPE, MODALS} from '../constants'; import eventsUi from './events/ui'; import planningUi from './planning/ui'; import {getItemType, gettext, getItemInArrayById, getErrorMessage, lockUtils} from '../utils'; +import {planningApi} from '../superdeskApi'; /** * Action Dispatcher to select an/all Event(s) @@ -180,6 +181,12 @@ const downloadEvents = (url, data) => { req.send(JSON.stringify(data)); }; +const bulkAddPlanningCoveragesToWorkflow = (items) => ( + (dispatch) => planningApi.planning.coverages.bulkAddCoverageToWorkflow(items) + .then(() => dispatch({ + type: MULTISELECT.ACTIONS.DESELECT_ALL_PLANNINGS, + })) +); const exportAsArticle = (items = [], download) => ( (dispatch, getState, {api, notify, gettext, superdesk, $location, $interpolate, desks}) => { @@ -312,6 +319,7 @@ const self = { itemBulkSpikeModal, itemBulkUnSpikeModal, exportAsArticle, + bulkAddPlanningCoveragesToWorkflow, }; export default self; diff --git a/client/api/editor/item.ts b/client/api/editor/item.ts index 267b62d96..93ee85933 100644 --- a/client/api/editor/item.ts +++ b/client/api/editor/item.ts @@ -1,3 +1,4 @@ +import {cloneDeep} from 'lodash'; import {EDITOR_TYPE, IEditorAPI} from '../../interfaces'; import {planningApi} from '../../superdeskApi'; @@ -24,7 +25,7 @@ export function getItemInstance(type: EDITOR_TYPE): IEditorAPI['item'] { return Object.keys(plans) .filter((planId) => plans[planId].event_item === eventId) - .map((planId) => plans[planId]); + .map((planId) => cloneDeep(plans[planId])); } return { diff --git a/client/api/events.ts b/client/api/events.ts index 266d4cd0b..fb029428c 100644 --- a/client/api/events.ts +++ b/client/api/events.ts @@ -1,21 +1,25 @@ import { FILTER_TYPE, IEventItem, - IPlanningAPI, IPlanningItem, + IPlanningAPI, ISearchAPIParams, ISearchParams, ISearchSpikeState, - LOCK_STATE + IPlanningConfig, + IEventUpdateMethod, } from '../interfaces'; +import {appConfig as config} from 'appConfig'; import {IRestApiResponse} from 'superdesk-api'; import {planningApi, superdeskApi} from '../superdeskApi'; import {EVENTS, TEMP_ID_PREFIX} from '../constants'; import {arrayToString, convertCommonParams, cvsToString, searchRaw, searchRawGetAll} from './search'; -import {eventUtils, planningUtils} from '../utils'; +import {eventUtils} from '../utils'; import {eventProfile, eventSearchProfile} from '../selectors/forms'; import * as actions from '../actions'; +const appConfig = config as IPlanningConfig; + function convertEventParams(params: ISearchParams): Partial { return { reference: params.reference, @@ -118,62 +122,95 @@ function getEventSearchProfile() { return eventSearchProfile(planningApi.redux.store.getState()); } -function createOrUpdatePlannings( - event: IEventItem, - items: Array> -): Promise> { - return Promise.all( - items.map( - (updates) => ( - updates._id.startsWith(TEMP_ID_PREFIX) ? - planningApi.planning.createFromEvent(event, updates) : - planningApi.planning.getById(updates._id) - .then((original) => ( - planningApi.planning.update(original, updates) - )) - ) - ) - ) - .then((newOrUpdatedItems) => { - newOrUpdatedItems.forEach(planningUtils.modifyForClient); - - return newOrUpdatedItems; - }); -} - function create(updates: Partial): Promise> { + const {default_create_planning_series_with_event_series} = appConfig.planning; + const planningDefaultCreateMethod: IEventUpdateMethod = default_create_planning_series_with_event_series === true ? + 'all' : + 'single'; + return superdeskApi.dataApi.create>('events', { ...updates, associated_plannings: undefined, + embedded_planning: updates.associated_plannings.map((planning) => ({ + update_method: planning.update_method ?? planningDefaultCreateMethod, + coverages: planning.coverages.map((coverage) => ({ + coverage_id: coverage.coverage_id, + g2_content_type: coverage.planning.g2_content_type, + desk: coverage.assigned_to.desk, + user: coverage.assigned_to.user, + language: coverage.planning.language, + news_coverage_status: coverage.news_coverage_status.qcode, + scheduled: coverage.planning.scheduled, + genre: coverage.planning.genre?.qcode, + slugline: coverage.planning.slugline, + ednote: coverage.planning.ednote, + internal_note: coverage.planning.internal_note, + headline: coverage.planning.headline, + })), + })), + update_method: updates.update_method?.value ?? updates.update_method }) .then((response) => { - const events: Array = modifySaveResponseForClient(response); - - return createOrUpdatePlannings(events[0], updates.associated_plannings ?? []) - .then((plannings) => { - // Make sure to update the Redux Store with the latest Planning items - // So that the Editor can set the state with these latest items - planningApi.redux.store.dispatch(actions.planning.api.receivePlannings(plannings)); + const events = modifySaveResponseForClient(response); - return events; - }); + return planningApi.planning.searchGetAll({ + recurrence_id: events[0].recurrence_id, + event_item: events[0].recurrence_id != null ? null : events.map((event) => event._id), + spike_state: 'both', + only_future: false, + }).then((planningItems) => { + // Make sure to update the Redux Store with the latest Planning items + // So that the Editor can set the state with these latest items + planningApi.redux.store.dispatch(actions.planning.api.receivePlannings(planningItems)); + + return events; + }); + }) + .catch((error) => { + console.error(error); + + return Promise.reject(error); }); } function update(original: IEventItem, updates: Partial): Promise> { - return superdeskApi.dataApi.patch('events', original, { + return superdeskApi.dataApi.patch('events', original, { ...updates, associated_plannings: undefined, + embedded_planning: updates?.associated_plannings?.map((planning) => ({ + planning_id: planning._id.startsWith(TEMP_ID_PREFIX) ? undefined : planning._id, + update_method: planning.update_method, + coverages: planning.coverages.map((coverage) => ({ + coverage_id: coverage.coverage_id, + g2_content_type: coverage.planning.g2_content_type, + desk: coverage.assigned_to.desk, + user: coverage.assigned_to.user, + language: coverage.planning.language, + news_coverage_status: coverage.news_coverage_status.qcode, + scheduled: coverage.planning.scheduled, + genre: coverage.planning.genre?.qcode, + slugline: coverage.planning.slugline, + ednote: coverage.planning.ednote, + internal_note: coverage.planning.internal_note, + })), + })), + update_method: updates.update_method?.value ?? updates.update_method ?? original.update_method }) .then((response) => { const events = modifySaveResponseForClient(response); - return createOrUpdatePlannings(events[0], updates.associated_plannings ?? []) - .then((plannings) => { - planningApi.redux.store.dispatch(actions.planning.api.receivePlannings(plannings)); - - return events; - }); + return planningApi.planning.searchGetAll({ + recurrence_id: events[0].recurrence_id, + event_item: events[0].recurrence_id != null ? null : events.map((event) => event._id), + spike_state: 'both', + only_future: false, + }).then((planningItems) => { + // Make sure to update the Redux Store with the latest Planning items + // So that the Editor can set the state with these latest items + planningApi.redux.store.dispatch(actions.planning.api.receivePlannings(planningItems)); + + return events; + }); }); } diff --git a/client/api/locks.ts b/client/api/locks.ts index fcc56baa6..aae3f29ff 100644 --- a/client/api/locks.ts +++ b/client/api/locks.ts @@ -42,9 +42,15 @@ function loadLockedItems(types?: Array<'events_and_planning' | 'featured_plannin } } + const lockedItemIds = lockUtils.getLockedItemIds(locks); + + if (lockedItemIds.length === 0) { + return Promise.resolve(); + } + // Make sure that all items that are locked are loaded into the store return planningApi.combined.searchGetAll({ - item_ids: lockUtils.getLockedItemIds(locks), + item_ids: lockedItemIds, only_future: false, include_killed: true, spike_state: 'draft', @@ -228,7 +234,16 @@ function unlockItem(item: T, reloadLocksIfN } } - const lockedItemId = currentLock.item_id; + let lockedItemId: string; + + if (item.type === 'event' && item.recurrence_id === currentLock.item_id) { + lockedItemId = item._id; + } else if (item.type === 'planning' && item.recurrence_id === currentLock.item_id) { + lockedItemId = item.event_item; + } else { + lockedItemId = currentLock.item_id; + } + const resource = getLockResourceName(currentLock.item_type); const endpoint = `${resource}/${lockedItemId}/unlock`; diff --git a/client/api/planning.ts b/client/api/planning.ts index 845539c55..56f44e960 100644 --- a/client/api/planning.ts +++ b/client/api/planning.ts @@ -9,7 +9,10 @@ import { ISearchAPIParams, ISearchParams, ISearchSpikeState, + IPlanningConfig, } from '../interfaces'; +import {appConfig as config} from 'appConfig'; + import {arrayToString, convertCommonParams, searchRaw, searchRawGetAll, cvsToString} from './search'; import {planningApi, superdeskApi} from '../superdeskApi'; import {IRestApiResponse} from 'superdesk-api'; @@ -20,6 +23,8 @@ import {PLANNING} from '../constants'; import * as selectors from '../selectors'; import * as actions from '../actions'; +const appConfig = config as IPlanningConfig; + function convertPlanningParams(params: ISearchParams): Partial { return { agendas: arrayToString(params.agendas), @@ -141,13 +146,7 @@ function getPlanningSearchProfile() { } function create(updates: Partial): Promise { - // If the Planning item has coverages, then we need to create the Planning first - // before saving the coverages - // As Assignments are created and require a Planning ID - return !updates.coverages?.length ? - superdeskApi.dataApi.create('planning', updates) : - superdeskApi.dataApi.create('planning', {...updates, coverages: []}) - .then((item) => update(item, updates)); + return superdeskApi.dataApi.create('planning', updates); } function update(original: IPlanningItem, updates: Partial): Promise { @@ -159,20 +158,26 @@ function update(original: IPlanningItem, updates: Partial): Promi } function createFromEvent(event: IEventItem, updates: Partial): Promise { - return create(planningUtils.modifyForServer({ - slugline: event.slugline, - planning_date: event._sortDate ?? event.dates.start, - internal_note: event.internal_note, - name: event.name, - place: event.place, - subject: event.subject, - anpa_category: event.anpa_category, - description_text: event.definition_short, - ednote: event.ednote, - language: event.language, - ...updates, - event_item: event._id, - })); + if (updates.update_method == null && appConfig.planning.default_create_planning_series_with_event_series === true) { + updates.update_method = 'all'; + } + + return create( + planningUtils.modifyForServer({ + slugline: event.slugline, + planning_date: event._sortDate ?? event.dates.start, + internal_note: event.internal_note, + name: event.name, + place: event.place, + subject: event.subject, + anpa_category: event.anpa_category, + description_text: event.definition_short, + ednote: event.ednote, + language: event.language, + ...updates, + event_item: event._id, + }), + ); } function setDefaultValues( @@ -195,6 +200,43 @@ function setDefaultValues( ); } +function bulkAddCoverageToWorkflow(planningItems: Array): Promise> { + const {getState, dispatch} = planningApi.redux.store; + const {gettext} = superdeskApi.localization; + const {notify} = superdeskApi.ui; + + const coverageStatuses = selectors.general.newsCoverageStatus(getState()); + const planningItemsToUpdate: Array = planningItems.filter((item) => item.lock_action !== 'edit'); + const allUpdates = planningItemsToUpdate.map((plan) => { + const updates = {coverages: cloneDeep(plan.coverages)}; + + updates.coverages = plan.coverages + .map((coverage) => planningUtils.getActiveCoverage(coverage, coverageStatuses)); + + return planning.update(plan, updates) + .then((updatedPlan) => { + dispatch(actions.planning.api.receivePlannings([updatedPlan])); + + return updatedPlan; + }); + }); + + return Promise.all(allUpdates) + .then((result) => { + notify.success(gettext('Coverages added to workflow.')); + + return result; + }) + .catch((error) => { + notify.error(getErrorMessage( + error, + gettext('Failed to add coverages to workflow') + )); + + return Promise.reject(error); + }); +} + function addCoverageToWorkflow( plan: IPlanningItem, coverage: IPlanningCoverageItem, @@ -240,5 +282,6 @@ export const planning: IPlanningAPI['planning'] = { coverages: { setDefaultValues: setDefaultValues, addCoverageToWorkflow: addCoverageToWorkflow, + bulkAddCoverageToWorkflow: bulkAddCoverageToWorkflow, }, }; diff --git a/client/api/search.ts b/client/api/search.ts index 66cd2b1e3..7892f84ee 100644 --- a/client/api/search.ts +++ b/client/api/search.ts @@ -3,6 +3,7 @@ import {superdeskApi} from '../superdeskApi'; import {IRestApiResponse} from 'superdesk-api'; import {getDateTimeElasticFormat, getTimeZoneOffset} from '../utils'; import {default as timeUtils} from '../utils/time'; +import {appConfig} from 'appConfig'; export function cvsToString(items?: Array<{[key: string]: any}>, field: string = 'qcode'): string { @@ -36,7 +37,7 @@ export function convertCommonParams(params: ISearchParams): Partial; + workspace?: string; + listGroups?: Array; + privileges?: any; +} + +interface IReduxDispatchProps { + fetchMyAssignmentsCount?: () => any; + changeAssignmentListSingleGroupView?: (groupKey: string) => any; + loadAssignments: (filters: any) => any; + changeSortField?: (field: string, saveSortPreferences?: boolean) => any; + changeDayField?: (value: string) => any; +} + +interface IOwnProps { + archiveItem?: any; + withArchiveItem?: boolean; + showAllDeskOption?: boolean; + saveSortPreferences?: boolean; + ignoreScheduledUpdates?: boolean; +} + +type IProps = IOwnProps & IReduxStateProps & IReduxDispatchProps; + +export class AssignmentsSubNavComponent extends React.Component { constructor(props) { super(props); @@ -40,6 +78,7 @@ export class AssignmentsSubNavComponent extends React.Component { const { filterBy, orderByField, + dayField, loadAssignments, filterByType, filterByPriority, @@ -51,6 +90,7 @@ export class AssignmentsSubNavComponent extends React.Component { filterBy, searchQuery, orderByField, + dayField, filterByType, filterByPriority, selectedDeskId, @@ -68,12 +108,14 @@ export class AssignmentsSubNavComponent extends React.Component { filterByType, filterByPriority, ignoreScheduledUpdates, + dayField } = this.props; loadAssignments({ filterBy, searchQuery, orderByField, + dayField, filterByType, filterByPriority, selectedDeskId, @@ -82,7 +124,7 @@ export class AssignmentsSubNavComponent extends React.Component { } changeSortField(field) { - const {changeSortField, saveSortPreferences} = this.props; + const {changeSortField, saveSortPreferences = true} = this.props; changeSortField(field, saveSortPreferences); } @@ -102,6 +144,7 @@ export class AssignmentsSubNavComponent extends React.Component { filterBy, myAssignmentsCount, orderByField, + dayField, searchQuery, assignmentListSingleGroupView, changeAssignmentListSingleGroupView, @@ -111,8 +154,9 @@ export class AssignmentsSubNavComponent extends React.Component { workspace, userDesks, currentDeskId, - showAllDeskOption, + showAllDeskOption = false, privileges, + changeDayField, } = this.props; // Show the Desk selection if we're in Fulfil Assignment or Custom Workspace @@ -137,6 +181,8 @@ export class AssignmentsSubNavComponent extends React.Component { myAssignmentsCount={myAssignmentsCount} orderByField={orderByField} changeFilter={this.changeFilter} + dayField={dayField} + changeDayField={changeDayField} selectedDeskId={selectedDeskId} userDesks={showDeskSelection ? userDesks : []} selectAssignmentsFrom={this.selectAssignmentsFrom} @@ -150,46 +196,13 @@ export class AssignmentsSubNavComponent extends React.Component { } } -AssignmentsSubNavComponent.propTypes = { - filterBy: PropTypes.string, - selectedDeskId: PropTypes.string, - myAssignmentsCount: PropTypes.number, - orderByField: PropTypes.string, - fetchMyAssignmentsCount: PropTypes.func, - searchQuery: PropTypes.string, - assignmentListSingleGroupView: PropTypes.string, - changeAssignmentListSingleGroupView: PropTypes.func, - loadAssignments: PropTypes.func.isRequired, - filterByType: PropTypes.string, - filterByPriority: PropTypes.string, - assignmentsInTodoCount: PropTypes.number, - assignmentsInInProgressCount: PropTypes.number, - assignmentsInCompletedCount: PropTypes.number, - archiveItem: PropTypes.object, - withArchiveItem: PropTypes.bool, - userDesks: PropTypes.array, - workspace: PropTypes.string, - currentDeskId: PropTypes.string, - listGroups: PropTypes.array, - assignmentCounts: PropTypes.object, - showAllDeskOption: PropTypes.bool, - changeSortField: PropTypes.func, - saveSortPreferences: PropTypes.bool, - ignoreScheduledUpdates: PropTypes.bool, - privileges: PropTypes.object, -}; - -AssignmentsSubNavComponent.defaultProps = { - showAllDeskOption: false, - saveSortPreferences: true, -}; - const mapStateToProps = (state) => ({ filterBy: selectors.getFilterBy(state), selectedDeskId: selectors.getSelectedDeskId(state), currentDeskId: selectors.general.currentDeskId(state), myAssignmentsCount: selectors.getMyAssignmentsCount(state), orderByField: selectors.getOrderByField(state), + dayField: selectors.getDayField(state), searchQuery: selectors.getSearchQuery(state), assignmentListSingleGroupView: selectors.getAssignmentListSingleGroupView(state), @@ -222,12 +235,13 @@ const mapDispatchToProps = (dispatch) => ({ ) ), loadAssignments: (filters) => dispatch(actions.assignments.ui.loadAssignments(filters)), + changeDayField: (value) => dispatch(actions.assignments.ui.changeDayField(value)), changeSortField: (field, saveSortPreferences) => ( dispatch(actions.assignments.ui.changeSortField(field, saveSortPreferences)) ), }); -export const AssignmentsSubNav = connect( +export const AssignmentsSubNav = connect( mapStateToProps, mapDispatchToProps )(AssignmentsSubNavComponent); diff --git a/client/apps/PageContent.tsx b/client/apps/PageContent.tsx index 97db04fe7..055343c45 100644 --- a/client/apps/PageContent.tsx +++ b/client/apps/PageContent.tsx @@ -145,6 +145,7 @@ export class PageContent extends React.Component, IState> { {ListPanel && (
; + searchFilters: Array; openPreview(item: IEventOrPlanningItem): void; edit(item: IEventOrPlanningItem): void; @@ -94,7 +96,8 @@ const mapStateToProps = (state) => ({ contacts: selectors.general.contactsById(state), listViewType: selectors.main.getCurrentListViewType(state), sortField: selectors.main.getCurrentSortField(state), - currentSearch: selectors.main.currentSearch(state) + currentSearch: selectors.main.currentSearch(state), + searchFilters: selectors.eventsPlanning.combinedViewFilters(state), }); const mapDispatchToProps = (dispatch) => ({ @@ -140,6 +143,10 @@ export class PlanningListComponent extends React.PureComponent { } render() { + const activeSearchFilter = this.props.searchFilters.filter((filter) => + filter.item_type === FILTER_TYPE[this.props.activeFilter] && + filter._id === this.props.currentSearch?.filter_id); + const { groups, agendas, @@ -211,6 +218,7 @@ export class PlanningListComponent extends React.PureComponent { sortField={sortField} indexItems searchParams={currentSearch.advancedSearch} + searchFilterParams={activeSearchFilter[0]?.params} /> ); diff --git a/client/apps/Planning/PlanningListSubNav.tsx b/client/apps/Planning/PlanningListSubNav.tsx index b898c7b73..479cf9252 100644 --- a/client/apps/Planning/PlanningListSubNav.tsx +++ b/client/apps/Planning/PlanningListSubNav.tsx @@ -285,12 +285,17 @@ class PlanningListSubNavComponent extends React.Component { onClick={() => this.props.jumpTo('FORWARD')} icon="chevron-right-thin" /> - - - {intervalText} - - - +
+ + + {intervalText} + + + +
)} diff --git a/client/components/Assignments/AssignmentEditor.tsx b/client/components/Assignments/AssignmentEditor.tsx index 24436ffee..543f2bbf4 100644 --- a/client/components/Assignments/AssignmentEditor.tsx +++ b/client/components/Assignments/AssignmentEditor.tsx @@ -14,9 +14,9 @@ import { Row, SelectInput, ColouredValueInput, - SelectUserInput, } from '../UI/Form'; import {ContactsPreviewList, SelectSearchContactsField} from '../Contacts'; +import {superdeskApi} from '../../superdeskApi'; export class AssignmentEditorComponent extends React.Component { constructor(props) { @@ -243,6 +243,7 @@ export class AssignmentEditorComponent extends React.Component { showPriority, className, } = this.props; + const {SelectUser} = superdeskApi.components; return (
@@ -297,15 +298,20 @@ export class AssignmentEditorComponent extends React.Component { /> ) : ( - + +
+ { + this.onUserChange(null, user); + }} + autoFocus={false} + horizontalSpacing={true} + clearable={true} + /> +
+
)} {showPriority && ( diff --git a/client/components/Assignments/AssignmentGroupList.tsx b/client/components/Assignments/AssignmentGroupList.tsx index 3684fa8be..0ce6a7551 100644 --- a/client/components/Assignments/AssignmentGroupList.tsx +++ b/client/components/Assignments/AssignmentGroupList.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {get, throttle} from 'lodash'; @@ -13,14 +12,58 @@ import {assignmentUtils} from '../../utils'; import {AssignmentItem} from './AssignmentItem'; import {Header, Group} from '../UI/List'; import {OrderDirectionIcon} from '../OrderBar'; -import {assignmentsViewRequiresArchiveItems} from './AssignmentItem/fields'; import {ListItemLoader} from 'superdesk-ui-framework/react/components/ListItemLoader'; +import moment from 'moment'; const focusElement = throttle((element: HTMLElement) => { element.focus(); }, 250, {leading: true}); -class AssignmentGroupListComponent extends React.Component { +interface IProps { + filterBy?: string; + orderByField?: string, + orderDirection?: string; + assignments: Array; + groupKey: string; + users?: Array; + session?: any; + loadMoreAssignments: (groupKey: string) => any; + lockedItems?: any; + currentAssignmentId?: string; + reassign?: () => any; + completeAssignment?: () => any; + editAssignmentPriority?: () => any; + hideItemActions?: boolean; + privileges?: any; + startWorking?: () => any; + totalCount?: number; + changeAssignmentListSingleGroupView?: (groupKey: string) => any; + assignmentListSingleGroupView?: string; + preview?: () => any; + priorities?: Array; + removeAssignment?: () => any; + openArchivePreview?: () => any; + revertAssignment?: () => any; + setMaxHeight?: boolean; + contentTypes?: Array; + desks?: Array; + groupLabel?: string; + groupStates?: Array; + groupEmptyMessage?: string; + showCount?: boolean; + changeListSortOrder?: (groupKey: string, order: any, savePreferences?: boolean) => any; + saveSortPreferences?: boolean; + contacts?: any; + isLoading?: boolean; + dayField?: string; +} + +interface IState { + isNextPageLoading: boolean; +} + +class AssignmentGroupListComponent extends React.Component { + dom: any; constructor(props) { super(props); this.state = {isNextPageLoading: false}; @@ -148,7 +191,7 @@ class AssignmentGroupListComponent extends React.Component { } changeListOrder(order) { - const {changeListSortOrder, groupKey, saveSortPreferences} = this.props; + const {changeListSortOrder, groupKey, saveSortPreferences = true} = this.props; changeListSortOrder(groupKey, order, saveSortPreferences); } @@ -205,7 +248,6 @@ class AssignmentGroupListComponent extends React.Component { contentTypes={contentTypes} assignedDesk={assignedDesk} contacts={contacts} - archiveItemForAssignment={this.props.archiveItemForAssignment} /> ); } @@ -214,18 +256,22 @@ class AssignmentGroupListComponent extends React.Component { const {gettext} = superdeskApi.localization; const { assignments, - totalCount, assignmentListSingleGroupView, - setMaxHeight, + setMaxHeight = true, groupLabel, groupEmptyMessage, - showCount, + showCount = true, changeAssignmentListSingleGroupView, orderDirection, isLoading, } = this.props; const listStyle = setMaxHeight ? {maxHeight: this.getListMaxHeight() + 'px'} : {}; const headingId = `heading--${this.props.groupKey}`; + const filteredAssignments = this.props.dayField == null + ? assignments + : assignments.filter((assignment) => + moment(assignment.planning.scheduled).isSameOrAfter(moment(this.props.dayField)), + ); return (
@@ -248,9 +294,9 @@ class AssignmentGroupListComponent extends React.Component {
{gettext( 'Number of Assignments: ', - {count: totalCount} + {count: (filteredAssignments?.length ?? 0)} )} - {totalCount} + {(filteredAssignments?.length ?? 0)}
)} @@ -283,8 +329,8 @@ class AssignmentGroupListComponent extends React.Component { )} {isLoading !== true && ( - get(assignments, 'length', 0) > 0 ? ( - assignments.map((assignment, index) => this.rowRenderer(index)) + (filteredAssignments?.length ?? 0) > 0 ? ( + filteredAssignments.map((_assignment, index) => this.rowRenderer(index)) ) : (
  • {groupEmptyMessage}
  • ) @@ -295,60 +341,15 @@ class AssignmentGroupListComponent extends React.Component { } } -AssignmentGroupListComponent.propTypes = { - filterBy: PropTypes.string, - orderByField: PropTypes.string, - orderDirection: PropTypes.string, - assignments: PropTypes.array.isRequired, - groupKey: PropTypes.string.isRequired, - users: PropTypes.array, - session: PropTypes.object, - loadMoreAssignments: PropTypes.func.isRequired, - lockedItems: PropTypes.object, - currentAssignmentId: PropTypes.string, - reassign: PropTypes.func, - completeAssignment: PropTypes.func, - editAssignmentPriority: PropTypes.func, - hideItemActions: PropTypes.bool, - privileges: PropTypes.object, - startWorking: PropTypes.func, - totalCount: PropTypes.number, - changeAssignmentListSingleGroupView: PropTypes.func, - assignmentListSingleGroupView: PropTypes.string, - preview: PropTypes.func, - priorities: PropTypes.array, - removeAssignment: PropTypes.func, - openArchivePreview: PropTypes.func, - revertAssignment: PropTypes.func, - setMaxHeight: PropTypes.bool, - contentTypes: PropTypes.array, - desks: PropTypes.array, - groupLabel: PropTypes.string, - groupStates: PropTypes.arrayOf(PropTypes.string), - groupEmptyMessage: PropTypes.string, - showCount: PropTypes.bool, - changeListSortOrder: PropTypes.func, - saveSortPreferences: PropTypes.bool, - contacts: PropTypes.object, - archiveItemForAssignment: PropTypes.object, - isLoading: PropTypes.bool, -}; - -AssignmentGroupListComponent.defaultProps = { - setMaxHeight: true, - showCount: true, - saveSortPreferences: true, -}; - const mapStateToProps = (state, ownProps) => { const assignmentDataSelector = selectors.getAssignmentGroupSelectors[ownProps.groupKey]; - const props = { + return { + dayField: selectors.getDayField(state), filterBy: selectors.getFilterBy(state), orderByField: selectors.getOrderByField(state), orderDirection: assignmentDataSelector.sortOrder(state), assignments: assignmentDataSelector.assignmentsSelector(state), - totalCount: assignmentDataSelector.countSelector(state), previewOpened: selectors.getPreviewAssignmentOpened(state), session: selectors.general.session(state), users: selectors.general.users(state), @@ -361,12 +362,6 @@ const mapStateToProps = (state, ownProps) => { contacts: selectors.general.contactsById(state), isLoading: assignmentDataSelector.isLoading(state), }; - - if (assignmentsViewRequiresArchiveItems()) { - props.archiveItemForAssignment = selectors.getStoredArchiveItems(state); - } - - return props; }; const mapDispatchToProps = (dispatch) => ({ diff --git a/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreviewContainer_test.tsx b/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreviewContainer_test.tsx index ee566fcd1..04d89a7c4 100644 --- a/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreviewContainer_test.tsx +++ b/client/components/Assignments/AssignmentPreviewContainer/AssignmentPreviewContainer_test.tsx @@ -49,7 +49,7 @@ describe('', () => { assignment.assigned_to.state = 'assigned'; let wrapper = getWrapper().find('.AssignmentPreview'); - expect(wrapper.children().length).toBe(4); + expect(wrapper.children().length).toBe(5); expect(wrapper.hasClass('AssignmentPreview')).toBe(true); @@ -61,7 +61,7 @@ describe('', () => { showFulfilAssignment: true, hideItemActions: true, }).find('.AssignmentPreview'); - expect(wrapper.children().length).toBe(5); + expect(wrapper.children().length).toBe(6); expect(wrapper.hasClass('AssignmentPreview')).toBe(true); expect(wrapper.childAt(0).type()).toEqual(AssignmentPreviewHeader); diff --git a/client/components/Assignments/AssignmentPreviewContainer/index.tsx b/client/components/Assignments/AssignmentPreviewContainer/index.tsx index 72ecb77a2..16d944a5e 100644 --- a/client/components/Assignments/AssignmentPreviewContainer/index.tsx +++ b/client/components/Assignments/AssignmentPreviewContainer/index.tsx @@ -1,22 +1,72 @@ import * as React from 'react'; -import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import {get} from 'lodash'; +import {IDesk, IUser} from 'superdesk-api'; +import { + IAssignmentItem, + IAssignmentPriority, + IEventItem, IFile, + IFormProfiles, + IG2ContentType, + ILockedItems, + IPlanningItem, + ISession +} from '../../../interfaces'; +import {superdeskApi} from '../../../superdeskApi'; import * as selectors from '../../../selectors'; import * as actions from '../../../actions'; -import {assignmentUtils, gettext, eventUtils, planningUtils, getFileDownloadURL} from '../../../utils'; +import {assignmentUtils, eventUtils, planningUtils, getFileDownloadURL} from '../../../utils'; import {ASSIGNMENTS, WORKSPACE} from '../../../constants'; +import {Button} from 'superdesk-ui-framework/react'; import {AssignmentPreviewHeader} from './AssignmentPreviewHeader'; import {AssignmentPreview} from './AssignmentPreview'; -import {Button} from '../../UI'; import {ContentBlock, ContentBlockInner} from '../../UI/SidePanel'; import {RelatedPlannings} from '../../RelatedPlannings'; import {EventMetadata} from '../../Events'; +import {PreviewFieldRelatedArticles} from '../../fields/preview/RelatedArticles'; -class AssignmentPreviewContainerComponent extends React.Component { +interface IOwnProps { + hideAvatar?: boolean; + hideItemActions?: boolean; + showFulfilAssignment?: boolean; +} + +interface IStateProps { + assignment: IAssignmentItem; + session: ISession; + users: Array; + desks: Array; + planningItem?: IPlanningItem; + eventItem?: IEventItem; + + priorities: Array; + privileges: {[key: string]: number}; + formProfile: IFormProfiles; + lockedItems: ILockedItems; + currentWorkspace: 'ASSIGNMENTS' | 'AUTHORING' | 'AUTHORING_WIDGET'; + contentTypes: Array; + files: Array; +} + +interface IDispatchProps { + startWorking(assignment: IAssignmentItem): void; + reassign(assignment: IAssignmentItem): void; + completeAssignment(assignment: IAssignmentItem): void; + revertAssignment(assignment: IAssignmentItem): void; + editAssignmentPriority(assignment: IAssignmentItem): void; + onFulFilAssignment(assignment: IAssignmentItem): void; + removeAssignment(assignment: IAssignmentItem): void; + openArchivePreview(assignment: IAssignmentItem): void; + fetchEventFiles(event: IEventItem): void; + fetchPlanningFiles(planning: IPlanningItem): void; +} + +type IProps = IOwnProps & IStateProps & IDispatchProps; + +class AssignmentPreviewContainerComponent extends React.Component { componentDidMount() { if (eventUtils.shouldFetchFilesForEvent(this.props.eventItem)) { this.props.fetchEventFiles(this.props.eventItem); @@ -83,6 +133,7 @@ class AssignmentPreviewContainerComponent extends React.Component { contentTypes, session, privileges, + lockedItems, files, } = this.props; @@ -90,12 +141,14 @@ class AssignmentPreviewContainerComponent extends React.Component { return null; } + const {gettext} = superdeskApi.localization; const planning = get(assignment, 'planning', {}); const itemActions = this.getItemActions(); const canFulfilAssignment = showFulfilAssignment && assignmentUtils.canFulfilAssignment( assignment, session, - privileges + privileges, + lockedItems ); return ( @@ -116,9 +169,11 @@ class AssignmentPreviewContainerComponent extends React.Component {
    ) : ( @@ -66,9 +83,26 @@ export const FiltersBar = ({ /> )} - + { + if (val == null) { + changeDayField(null); + } else { + changeDayField(val.toString()); + } + }} + dateFormat={appConfig.view.dateformat} + data-test-id="date-input" + /> -
    ); }; - -FiltersBar.propTypes = { - filterBy: PropTypes.string, - myAssignmentsCount: PropTypes.number, - orderByField: PropTypes.string, - changeFilter: PropTypes.func.isRequired, - changeSortField: PropTypes.func.isRequired, - userDesks: PropTypes.array, - selectedDeskId: PropTypes.string, - selectAssignmentsFrom: PropTypes.func, - showDeskSelection: PropTypes.bool, - showAllDeskOption: PropTypes.bool, - showDeskAssignmentView: PropTypes.bool, -}; - -FiltersBar.defaultProps = { - filterBy: 'Desk', - myAssignmentsCount: 0, - orderByField: 'Updated', - userDesks: [], - selectedDeskId: '', - workspace: '', - showDeskSelection: false, - showAllDeskOption: false, - showDeskAssignmentView: false, -}; - diff --git a/client/components/ConfirmationModal.tsx b/client/components/ConfirmationModal.tsx index 5c07b5763..b6485123a 100644 --- a/client/components/ConfirmationModal.tsx +++ b/client/components/ConfirmationModal.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import {gettext} from '../utils'; @@ -7,7 +6,30 @@ import {Modal} from './index'; import {ButtonList, Icon} from './UI'; import {KEYCODES} from '../constants'; -export class ConfirmationModal extends React.Component { +interface IProps { + handleHide(itemType?: string): void; + modalProps: { + onCancel?(): void; + cancelText?: string; + ignore?(): void; + showIgnore?: boolean; + ignoreText?: string; + okText?: string; + action?(): void; + title?: string; + body: React.ReactNode; + itemType?: string; + autoClose?: boolean; + large?: boolean; + bodyClassname?: string; + }; +} + +interface IState { + submitting: boolean; +} + +export class ConfirmationModal extends React.Component { constructor(props) { super(props); @@ -96,14 +118,18 @@ export class ConfirmationModal extends React.Component { } return ( - +

    {modalProps.title || gettext('Confirmation')}

    - +
    {modalProps.body || gettext('Are you sure ?')}
    @@ -115,23 +141,3 @@ export class ConfirmationModal extends React.Component { ); } } - -ConfirmationModal.propTypes = { - handleHide: PropTypes.func.isRequired, - modalProps: PropTypes.shape({ - onCancel: PropTypes.func, - cancelText: PropTypes.string, - ignore: PropTypes.func, - showIgnore: PropTypes.bool, - ignoreText: PropTypes.string, - okText: PropTypes.string, - action: PropTypes.func, - title: PropTypes.string, - body: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.element, - ]), - itemType: PropTypes.string, - autoClose: PropTypes.bool, - }), -}; diff --git a/client/components/ContentProfiles/FieldTab/FieldEditor.tsx b/client/components/ContentProfiles/FieldTab/FieldEditor.tsx index bc5857e2d..faba959ce 100644 --- a/client/components/ContentProfiles/FieldTab/FieldEditor.tsx +++ b/client/components/ContentProfiles/FieldTab/FieldEditor.tsx @@ -196,6 +196,7 @@ export class FieldEditor extends React.Component { item: this.props.item, onChange: this.onChange, errors: this.state.errors, + showErrors: true, }, fieldProps )} diff --git a/client/components/ContentProfiles/GroupTab/index.tsx b/client/components/ContentProfiles/GroupTab/index.tsx index 67532d40b..2e4cbc87b 100644 --- a/client/components/ContentProfiles/GroupTab/index.tsx +++ b/client/components/ContentProfiles/GroupTab/index.tsx @@ -103,13 +103,22 @@ export class GroupTabComponent extends React.Component { isEditorDirty() { if (this.state.selectedGroup != null) { + /** + * If a new group is being created, its not in this.props.profile.groups, + * making the next checks unnecessary. Regardless of that, if this is the case, + * there's no need to check further. + */ + if (this.state.creatingNewGroup) { + return true; + } + const originalGroup = this.props.profile.groups[this.state.selectedGroup._id]; const updatedGroup = { ...this.state.selectedGroup, index: originalGroup.index, }; - return this.state.creatingNewGroup || !isEqual( + return !isEqual( originalGroup, updatedGroup ); diff --git a/client/components/Coverages/CoverageAddAdvancedModal.tsx b/client/components/Coverages/CoverageAddAdvancedModal.tsx index b2edfdff2..d160136bc 100644 --- a/client/components/Coverages/CoverageAddAdvancedModal.tsx +++ b/client/components/Coverages/CoverageAddAdvancedModal.tsx @@ -9,7 +9,8 @@ import {getUserInterfaceLanguageFromCV} from '../../utils/users'; import {getVocabularyItemFieldTranslated} from '../../utils/vocabularies'; import Modal from '../Modal'; -import {SelectInput, SelectUserInput} from '../UI/Form'; +import {SelectInput} from '../UI/Form'; +import {superdeskApi} from '../../superdeskApi'; const isInvalid = (coverage) => coverage.user && !coverage.desk; @@ -34,8 +35,8 @@ interface ICoverageSelector { qcode: IG2ContentType['qcode']; name: IG2ContentType['name']; icon: string; - desk: IDesk['_id']; - user: IUser['_id']; + desk: IDesk; + user: IUser; status: IPlanningNewsCoverageStatus; popupContainer: any; filteredDesks: Array; @@ -218,6 +219,7 @@ export class CoverageAddAdvancedModal extends React.Component { render() { const language = getUserInterfaceLanguageFromCV(); + const {SelectUser} = superdeskApi.components; return ( { event.stopPropagation(); this.props.close(); }} + removeTabIndexAttribute={true} >

    @@ -276,18 +279,26 @@ export class CoverageAddAdvancedModal extends React.Component {

    - this.onUserChange(coverage, value)} - labelField="name" - keyField="_id" - users={coverage.filteredUsers} - clearable={true} - popupContainer={() => coverage.popupContainer} - inline={true} - /> -
    coverage.popupContainer = node} /> +
    + { + this.onUserChange(coverage, user); + }} + autoFocus={false} + horizontalSpacing={true} + clearable={true} + /> +
    coverage.popupContainer = node} /> +
    diff --git a/client/components/Coverages/CoverageIcons.tsx b/client/components/Coverages/CoverageIcons.tsx index 08aa5ce11..a24bb7582 100644 --- a/client/components/Coverages/CoverageIcons.tsx +++ b/client/components/Coverages/CoverageIcons.tsx @@ -80,6 +80,7 @@ export function getAvatarForCoverage( displayName: user.display_name, icon: icon, customContent: getCustomAvatarContent(user), + statusDot: {color: planningUtils.getNewsCoverageStatusDotColor(coverage)}, }; return avatar; diff --git a/client/components/Coverages/CoverageItem.tsx b/client/components/Coverages/CoverageItem.tsx index 100118b46..ad5aeb24e 100644 --- a/client/components/Coverages/CoverageItem.tsx +++ b/client/components/Coverages/CoverageItem.tsx @@ -7,7 +7,7 @@ import {IContactItem, IG2ContentType, IPlanningCoverageItem, IPlanningItem} from import * as selectors from '../../selectors'; import * as actions from '../../actions'; -import {WORKFLOW_STATE} from '../../constants'; +import {COVERAGES, WORKFLOW_STATE} from '../../constants'; import { getCreator, getItemInArrayById, @@ -20,6 +20,7 @@ import {getUserInterfaceLanguageFromCV} from '../../utils/users'; import {Item, Column, Row, Border, ActionMenu} from '../UI/List'; import {StateLabel, InternalNoteLabel} from '../../components'; import {CoverageIcons} from './CoverageIcons'; +import {Label} from 'superdesk-ui-framework'; interface IProps { coverage: IPlanningCoverageItem; @@ -39,6 +40,7 @@ interface IProps { } interface IState { + addedToWorkflow: boolean; userAssigned?: IUser; deskAssigned?: IDesk; coverageProvider?: string; @@ -70,6 +72,7 @@ export class CoverageItemComponent extends React.Component { coverageDateText: '', internalNoteFieldPrefix: '', coverageInWorkflow: false, + addedToWorkflow: false, }; this.updateViewAttributes = this.updateViewAttributes.bind(this); @@ -120,6 +123,7 @@ export class CoverageItemComponent extends React.Component { userAssigned: null, displayContentType: '', coverageDateText: '', + addedToWorkflow: coverage.workflow_status === COVERAGES.WORKFLOW_STATE.ACTIVE, }; if (!isPreview) { @@ -220,7 +224,7 @@ export class CoverageItemComponent extends React.Component { )} - + { `${this.state.internalNoteFieldPrefix}.workflow_status` : 'state'} showHeaderText={false} /> + {this.state.addedToWorkflow && ( +
    +
    + )} { return (
    - + { (toggle) => (
    { > - {renderFields(get(listFields, 'event.primary_fields', - EVENTS.LIST.PRIMARY_FIELDS), item, {}, language)} + {renderFields( + listFields?.event?.primary_fields ?? EVENTS.LIST.PRIMARY_FIELDS, + item, + {}, + language, + )} diff --git a/client/components/Events/EventScheduleSummary/index.tsx b/client/components/Events/EventScheduleSummary/index.tsx index eb8e6ee59..a4fb349f6 100644 --- a/client/components/Events/EventScheduleSummary/index.tsx +++ b/client/components/Events/EventScheduleSummary/index.tsx @@ -1,24 +1,32 @@ import React from 'react'; -import PropTypes from 'prop-types'; import {get} from 'lodash'; +import {IEventItem} from 'interfaces'; +import {gettext, eventUtils, timeUtils} from '../../../utils'; + +import {FormLabel, Text, ContentDivider} from 'superdesk-ui-framework/react'; import {RepeatEventSummary} from '../RepeatEventSummary'; import {Row} from '../../UI/Preview'; -import {gettext, eventUtils, timeUtils} from '../../../utils'; + import './style.scss'; -import {IEventItem} from 'interfaces'; + + interface IProps { event: Partial, noPadding?: boolean, forUpdating?: boolean, useEventTimezone?: boolean + useFormLabelAndText?: boolean + addContentDivider?: boolean } export const EventScheduleSummary = ({ event, noPadding = false, forUpdating = false, - useEventTimezone = false + useEventTimezone = false, + useFormLabelAndText = false, + addContentDivider = false, }: IProps) => { if (!event) { return null; @@ -74,7 +82,32 @@ export const EventScheduleSummary = ({ currentDateLabel = gettext('Current Date (Based on Event timezone)'); } - return ( + return useFormLabelAndText ? ( + +
    + + + {currentDateText || ''} + +
    + {addContentDivider !== true ? null : ( + + )} + {doesRepeat !== true ? null : ( + +
    + + + {eventUtils.getRepeatSummaryForEvent(eventSchedule)} + +
    + {addContentDivider !== true ? null : ( + + )} +
    + )} +
    + ) : ( { {this.state.openSuggestsPopUp && ( { diff --git a/client/components/GeoLookupInput/LocationLookupResultItem.tsx b/client/components/GeoLookupInput/LocationLookupResultItem.tsx index 66124d541..f61147531 100644 --- a/client/components/GeoLookupInput/LocationLookupResultItem.tsx +++ b/client/components/GeoLookupInput/LocationLookupResultItem.tsx @@ -9,6 +9,7 @@ interface IProps { onClick?(): void; active?: boolean; location: Partial; + languageCode?: string; } export class LocationLookupResultItem extends React.PureComponent { @@ -23,7 +24,7 @@ export class LocationLookupResultItem extends React.PureComponent { )} > - {getLocationsShortName(this.props.location)} + {getLocationsShortName(this.props.location, this.props.languageCode)} ); diff --git a/client/components/GeoLookupInput/index.tsx b/client/components/GeoLookupInput/index.tsx index 7248ac540..58112660b 100644 --- a/client/components/GeoLookupInput/index.tsx +++ b/client/components/GeoLookupInput/index.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import {AddGeoLookupInput, GeoLookupInputComponent} from './AddGeoLookupInput'; +import {AddGeoLookupInput} from './AddGeoLookupInput'; import {LineInput, Label} from '../UI/Form'; import {ILocation} from '../../interfaces'; @@ -17,14 +17,14 @@ interface IProps { readOnly?: boolean; boxed?: boolean; noMargin?: boolean; - refNode?: React.RefObject; + refNode?: React.RefObject; language?: string; onChange(field: string, value?: Partial): void; onFocus?(): void; popupContainer?(): HTMLElement; onPopupOpen?(): void; onPopupClose?(): void; - showAddLocationForm(props: any): void; + showAddLocationForm?(props: any): Promise; } export class GeoLookupInput extends React.PureComponent { diff --git a/client/components/IgnoreCancelSaveModal.tsx b/client/components/IgnoreCancelSaveModal.tsx index b29b3a99d..fe4eb1f45 100644 --- a/client/components/IgnoreCancelSaveModal.tsx +++ b/client/components/IgnoreCancelSaveModal.tsx @@ -1,89 +1,105 @@ import React from 'react'; -import PropTypes from 'prop-types'; import {connect} from 'react-redux'; -import {get, isNil} from 'lodash'; -import {eventUtils, gettext, isItemPublic, isExistingItem} from '../utils'; -import {ITEM_TYPE, EVENTS} from '../constants'; +import {IEventItem, IEventOrPlanningItem, IEventUpdateMethod, IPlanningItem} from '../interfaces'; +import {gettext, isItemPublic, isExistingItem} from '../utils'; +import {EVENTS} from '../constants'; import * as selectors from '../selectors'; import {Row} from './UI/Preview'; -import {UpdateMethodSelection} from './ItemActionConfirmation'; -import {EventScheduleSummary} from './Events'; import {ConfirmationModal} from './'; +import {UpdateRecurringEventsForm} from './ItemActionConfirmation'; + +interface IBaseProps { + handleHide(itemType: IEventOrPlanningItem['_id']): void; + currentEditId: T['_id']; + modalProps: { + item: T; + updates: Partial; + onCancel(): void; + onIgnore(): void; + onSave( + withConfirmation: boolean, + updateMethod: string, + planningUpdateMethods: {[planningId: string]: IEventUpdateMethod} + ): void; + onGoTo(): void; + onSaveAndPost( + withConfirmation: boolean, + updateMethod: string, + planningUpdateMethods: {[planningId: string]: IEventUpdateMethod} + ): void; + title: string; + autoClose?: boolean; + bodyText?: string; + showIgnore?: boolean; + }; +} + +type IProps = IBaseProps | IBaseProps; -export class IgnoreCancelSaveModalComponent extends React.Component { - constructor(props) { +interface IState { + eventUpdateMethod: IEventUpdateMethod; + planningUpdateMethods: {[planningId: string]: IEventUpdateMethod}; +} + +export class IgnoreCancelSaveModalComponent extends React.Component { + constructor(props: IProps) { super(props); - this.state = {eventUpdateMethod: EVENTS.UPDATE_METHODS[0]}; + this.state = { + eventUpdateMethod: EVENTS.UPDATE_METHODS[0].value, + planningUpdateMethods: {}, + }; this.onEventUpdateMethodChange = this.onEventUpdateMethodChange.bind(this); + this.onPlanningUpdateMethodChange = this.onPlanningUpdateMethodChange.bind(this); this.onSubmit = this.onSubmit.bind(this); } - onEventUpdateMethodChange(field, option) { + onEventUpdateMethodChange(option: IEventUpdateMethod) { this.setState({eventUpdateMethod: option}); } - renderEvent() { - const {modalProps} = this.props; - - const { - item, - onSave, - } = modalProps || {}; - const {submitting} = this.state; - - const isRecurringEvent = eventUtils.isEventRecurring(item); - - return ( -
    - - - - - - - {onSave && ( - - )} -
    - ); + onPlanningUpdateMethodChange(planningId: IPlanningItem['_id'], updateMethod: IEventUpdateMethod) { + this.setState((prevState) => ({ + planningUpdateMethods: { + ...prevState.planningUpdateMethods, + [planningId]: updateMethod, + }, + })); } - renderPlanning() { - const {item} = get(this.props, 'modalProps') || {}; - - return ( -
    - + +
    + ); + } else if (this.props.modalProps.item.type === 'event') { + return ( + { + this.props.handleHide(this.props.modalProps.item.type); + }, + unlockOnClose: false, + }} /> -
    - ); + ); + } + + return null; } onSubmit() { @@ -94,13 +110,15 @@ export class IgnoreCancelSaveModalComponent extends React.Component { } else if (onSaveAndPost) { return onSaveAndPost( false, - this.state.eventUpdateMethod + this.state.eventUpdateMethod, + this.state.planningUpdateMethods, ); } return onSave( false, - this.state.eventUpdateMethod + this.state.eventUpdateMethod, + this.state.planningUpdateMethods, ); } @@ -128,22 +146,9 @@ export class IgnoreCancelSaveModalComponent extends React.Component { return okText; } - getRenderItem(itemType) { - switch (itemType) { - case ITEM_TYPE.EVENT: - return this.renderEvent(); - - case ITEM_TYPE.PLANNING: - return this.renderPlanning(); - } - - return null; - } - render() { const {handleHide, modalProps} = this.props; const { - itemType, title, onIgnore, onCancel, @@ -163,37 +168,22 @@ export class IgnoreCancelSaveModalComponent extends React.Component { modalProps={{ onCancel: onCancel, cancelText: gettext('Cancel'), - showIgnore: isNil(showIgnore) ? true : showIgnore, + showIgnore: showIgnore !== true, ignore: onIgnore, ignoreText: gettext('Ignore'), action: (onGoTo || onSave || onSaveAndPost) ? this.onSubmit : null, okText: okText, title: title || gettext('Save Changes?'), - body: bodyText || this.getRenderItem(itemType), + body: bodyText || this.renderItemDetails(), autoClose: autoClose, + large: true, + bodyClassname: 'p-3', }} /> ); } } -IgnoreCancelSaveModalComponent.propTypes = { - handleHide: PropTypes.func.isRequired, - modalProps: PropTypes.shape({ - item: PropTypes.object, - itemType: PropTypes.string, - onCancel: PropTypes.func, - onIgnore: PropTypes.func, - onSave: PropTypes.func, - onGoTo: PropTypes.func, - onSaveAndPost: PropTypes.func, - title: PropTypes.string, - autoClose: PropTypes.bool, - bodyText: PropTypes.string, - }), - currentEditId: PropTypes.string, -}; - const mapStateToProps = (state) => ({ currentEditId: selectors.forms.currentItemId(state), }); diff --git a/client/components/ItemActionConfirmation/forms/updateRecurringEventsForm.tsx b/client/components/ItemActionConfirmation/forms/updateRecurringEventsForm.tsx index 498a2e395..9284b4193 100644 --- a/client/components/ItemActionConfirmation/forms/updateRecurringEventsForm.tsx +++ b/client/components/ItemActionConfirmation/forms/updateRecurringEventsForm.tsx @@ -1,36 +1,135 @@ import React from 'react'; -import PropTypes from 'prop-types'; import {connect} from 'react-redux'; +import {cloneDeep, isEqual} from 'lodash'; + +import { + IEmbeddedCoverageItem, + IEventFormProfile, + IEventItem, + IEventUpdateMethod, + IPlanningItem, + PREVIEW_PANEL, +} from '../../../interfaces'; import {planningApi} from '../../../superdeskApi'; import * as actions from '../../../actions'; -import {get} from 'lodash'; -import {UpdateMethodSelection} from '../UpdateMethodSelection'; -import {EVENTS} from '../../../constants'; -import {EventScheduleSummary} from '../../Events'; +import {EVENTS, TEMP_ID_PREFIX} from '../../../constants'; import {eventUtils, gettext} from '../../../utils'; -import {onItemActionModalHide} from './utils'; +import {IModalProps, onItemActionModalHide} from './utils'; +import {storedPlannings} from '../../../selectors/planning'; +import {eventProfile} from '../../../selectors/forms'; -import {Row} from '../../UI/Preview'; +import {ContentDivider, Heading, Option, Select, Text, FormLabel} from 'superdesk-ui-framework/react'; +import {PlanningMetaData} from '../../RelatedPlannings/PlanningMetaData'; +import {previewGroupToProfile, renderGroupedFieldsForPanel} from '../../fields'; +import {getUserInterfaceLanguageFromCV} from '../../../utils/users'; import '../style.scss'; -export class UpdateRecurringEventsComponent extends React.Component { - constructor(props) { - super(props); - this.state = { - eventUpdateMethod: EVENTS.UPDATE_METHODS[0], - relatedEvents: [], - relatedPlannings: [], - }; +interface IOwnProps { + original: IEventItem; + updates: Partial; + modalProps: IModalProps; + enableSaveInModal?(): void; + resolve?(item?: IEventItem): void; + onEventUpdateMethodChange?(option: IEventUpdateMethod): void; + onPlanningUpdateMethodChange?(planningId: IPlanningItem['_id'], updateMethod: IEventUpdateMethod): void; +} - this.onEventUpdateMethodChange = this.onEventUpdateMethodChange.bind(this); - } +interface IStateProps { + originalPlanningItems: {[planningId: string]: IPlanningItem}; + eventProfile: IEventFormProfile; +} + +interface IDispatchProps { + onSubmit(original: IEventItem, updates: Partial): void; + onHide(original: IEventItem): void; +} + +type IProps = IOwnProps & IStateProps & IDispatchProps; +type IPlanningEmbeddedCoverageMap = {[planningId: string]: {[coverageId: string]: IEmbeddedCoverageItem}}; + +interface IState { + eventUpdateMethod: IEventUpdateMethod; + relatedEvents: Array; + relatedPlannings: Array; + posting: boolean; + diff: Partial; + eventModified: boolean; + recurringPlanningItemsToUpdate: Array; + recurringPlanningItemsToCreate: Array; + planningUpdateMethods: {[planningId: string]: IEventUpdateMethod}; +} + +function eventWasUpdated(original: IEventItem, updates: Partial): boolean { + const originalItem = eventUtils.modifyForServer(cloneDeep(original)); + const eventUpdates = eventUtils.getEventDiff(originalItem, updates); + const eventFields = Object.keys(eventUpdates).filter( + (field) => !['update_method', 'dates', 'associated_plannings'].includes(field) + ); + + return eventFields.length > 0; +} + +function getRecurringPlanningToUpdate( + original: IEventItem, + updates: Partial, + plannings: {[planningId: string]: IPlanningItem} +): Array { + const originalCoverages: IPlanningEmbeddedCoverageMap = (original.planning_ids || []) + .map((planningId) => plannings[planningId]) + .reduce((planningItems, planningItem) => { + planningItems[planningItem._id] = (planningItem.coverages ?? []).reduce( + (embeddedCoverages, coverage) => { + embeddedCoverages[coverage.coverage_id] = eventUtils.convertCoverageToEventEmbedded(coverage); + + return embeddedCoverages; + }, + {} + ); + + return planningItems; + }, {}); + + return (updates.associated_plannings ?? []) + .filter((planningItem) => { + if (planningItem._id.startsWith(TEMP_ID_PREFIX)) { + // This is a temporary Planning, therefor is not part of a recurring series of items + return false; + } else if (planningItem.planning_recurrence_id == null) { + // This Planning item part of a recurring series of items + return false; + } + + const embeddedCoverages = (planningItem.coverages ?? []).reduce( + (embeddedCoverages, coverage) => { + embeddedCoverages[coverage.coverage_id] = eventUtils.convertCoverageToEventEmbedded(coverage); + + return embeddedCoverages; + }, + {} + ); + + return !isEqual(embeddedCoverages, originalCoverages[planningItem._id]); + }) + .map((planningItem) => planningItem._id); +} + +function getRecurringPlanningToCreate(updates: Partial): Array { + return (updates.associated_plannings ?? []) + .filter((planningItem) => (planningItem._id.startsWith(TEMP_ID_PREFIX))) + .map((planningItem) => planningItem._id); +} + +export class UpdateRecurringEventsComponent extends React.Component { + constructor(props: IProps) { + super(props); - componentWillMount() { - const isRecurring = get(this.props, 'original.recurrence_id'); + const posting = (this.props.original._post ?? true) === true; + const isRecurring = this.props.original.recurrence_id != null; + let relatedEvents: Array = []; + let relatedPlannings: Array = []; if (isRecurring || eventUtils.eventHasPlanning(this.props.original)) { - this.posting = get(this.props.original, '_post', true); const event = isRecurring ? eventUtils.getRelatedEventsForRecurringEvent( this.props.original, @@ -39,103 +138,250 @@ export class UpdateRecurringEventsComponent extends React.Component { ) : this.props.original; - this.setState({ - relatedEvents: event._events, - relatedPlannings: this.posting ? [] : event._relatedPlannings, - }); + relatedEvents = event._events || []; + relatedPlannings = posting ? [] : event._relatedPlannings; } - // Enable save so that the user can update just this event. - this.props.enableSaveInModal(); + this.state = { + eventUpdateMethod: EVENTS.UPDATE_METHODS[0].value, + relatedEvents: relatedEvents, + relatedPlannings: relatedPlannings, + posting: posting, + diff: eventUtils.getEventDiff(this.props.original, this.props.updates), + eventModified: eventWasUpdated(this.props.original, this.props.updates), + recurringPlanningItemsToUpdate: getRecurringPlanningToUpdate( + this.props.original, + this.props.updates, + this.props.originalPlanningItems + ), + recurringPlanningItemsToCreate: getRecurringPlanningToCreate(this.props.updates), + planningUpdateMethods: {}, + }; + + this.onEventUpdateMethodChange = this.onEventUpdateMethodChange.bind(this); } - onEventUpdateMethodChange(field, option) { + componentDidMount() { + if (this.props.enableSaveInModal != null) { + // Enable save so that the user can update just this event. + this.props.enableSaveInModal(); + } + } + + onEventUpdateMethodChange(updateMethod: IEventUpdateMethod) { const event = eventUtils.getRelatedEventsForRecurringEvent( this.props.original, - option, + {value: updateMethod, name: updateMethod}, true ); this.setState({ - eventUpdateMethod: option, - relatedEvents: event._events, - relatedPlannings: this.posting ? [] : event._relatedPlannings, + eventUpdateMethod: updateMethod, + relatedEvents: event._events || [], + relatedPlannings: this.state.posting ? [] : event._relatedPlannings, }); + if (this.props.onEventUpdateMethodChange != null) { + this.props.onEventUpdateMethodChange(updateMethod); + } } submit() { - return this.props.onSubmit( - this.props.original, - { - ...this.props.updates, - update_method: this.state.eventUpdateMethod, + const updates = { + ...this.props.updates, + update_method: this.state.eventUpdateMethod, + }; + + updates.associated_plannings.forEach((planningItem) => { + if (this.state.planningUpdateMethods[planningItem._id] != null) { + planningItem.update_method = this.state.planningUpdateMethods[planningItem._id]; } + }); + + return this.props.onSubmit(this.props.original, updates); + } + + renderModifiedPlanningItems() { + const planningsToCreate = (this.props.updates.associated_plannings || []) + .filter((planningItem) => ( + this.state.recurringPlanningItemsToCreate.includes(planningItem._id) + )); + const planningsToUpdate = (this.props.updates.associated_plannings || []) + .filter((planningItem) => ( + this.state.recurringPlanningItemsToUpdate.includes(planningItem._id) + )); + + if (planningsToCreate.length === 0 && planningsToUpdate.length === 0) { + return null; + } + + return ( + + + {gettext('Related Planning(s)')} + + {planningsToCreate.map((item, index) => ( + this.renderPlanningItem(item, false, index === planningsToCreate.length - 1) + ))} + {planningsToCreate.length === 0 || planningsToUpdate.length === 0 ? null : ( + + )} + {planningsToUpdate.map((item, index) => ( + this.renderPlanningItem(item, true, index === planningsToUpdate.length - 1) + ))} + + ); + } + + renderPlanningItem(item: Partial, planningExists: boolean, lastItem: boolean) { + return ( + + + {planningExists === true ? ( + + + {gettext('You made changes to this planning item that is part of a recurring event.')} + +  {gettext('Apply the changes to all recurring planning items or just this one?')} + + ) : ( + + + {gettext('You are creating a new planning item.')} + +  {gettext('Add this item to all recurring events or just this one?')} + + )} + + + + {lastItem === true ? null : ( + + )} + ); } + onPlanningUpdateMethodChange(planningId: IPlanningItem['_id'], updateMethod: IEventUpdateMethod) { + this.setState((prevState) => ({ + planningUpdateMethods: { + ...prevState.planningUpdateMethods, + [planningId]: updateMethod, + }, + })); + if (this.props.onPlanningUpdateMethodChange != null) { + this.props.onPlanningUpdateMethodChange(planningId, updateMethod); + } + } + render() { - const {original, submitting} = this.props; + const {original} = this.props; const isRecurring = !!original.recurrence_id; const eventsInUse = this.state.relatedEvents.filter((e) => ( - get(e, 'planning_ids.length', 0) > 0 || 'pubstatus' in e + (e.planning_ids?.length ?? 0) > 0 || e.pubstatus != null )); const numEvents = this.state.relatedEvents.length + 1 - eventsInUse.length; return ( -
    - - - - - - - - - -
    + +
      + {renderGroupedFieldsForPanel( + 'simple-preview', + previewGroupToProfile(PREVIEW_PANEL.EVENT, this.props.eventProfile, false), + { + item: { + ...this.props.original, + ...this.props.updates, + }, + language: this.props.updates.language ?? + this.props.original.language ?? + getUserInterfaceLanguageFromCV(), + useFormLabelAndText: true, + schema: this.props.eventProfile.schema, + profile: this.props.eventProfile, + addContentDivider: true, + }, + {}, + )} + {this.state.eventModified === false || isRecurring !== true ? null : ( + +
      + + + {numEvents} + +
      + +
      + )} +
    + + {this.state.eventModified === false ? null : ( + + + {gettext('This is a recurring event.')} + {gettext('Update all recurring events or just this one?')} + + + + )} + {this.renderModifiedPlanningItems()} +
    ); } } -UpdateRecurringEventsComponent.propTypes = { - original: PropTypes.object.isRequired, - updates: PropTypes.object.isRequired, - submitting: PropTypes.bool, - onSubmit: PropTypes.func, - enableSaveInModal: PropTypes.func, - resolve: PropTypes.func, -}; +const mapStateToProps = (state) => ({ + originalPlanningItems: storedPlannings(state), + eventProfile: eventProfile(state), +}); -const mapDispatchToProps = (dispatch, ownProps) => ({ +const mapDispatchToProps = (dispatch: any, ownProps: IOwnProps) => ({ onSubmit: (original, updates) => ( dispatch(actions.main.save(original, updates, false)) .then((savedItem) => { @@ -143,7 +389,7 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ planningApi.locks.unlockItem(savedItem); } - if (ownProps.resolve) { + if (ownProps.resolve != null) { ownProps.resolve(savedItem); } }) @@ -157,8 +403,8 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ ), }); -export const UpdateRecurringEventsForm = connect( - null, +export const UpdateRecurringEventsForm = connect( + mapStateToProps, mapDispatchToProps, null, {forwardRef: true} diff --git a/client/components/ItemActionConfirmation/forms/utils.ts b/client/components/ItemActionConfirmation/forms/utils.ts index e04cad702..4163736c3 100644 --- a/client/components/ItemActionConfirmation/forms/utils.ts +++ b/client/components/ItemActionConfirmation/forms/utils.ts @@ -1,8 +1,9 @@ import {IAssignmentOrPlanningItem} from '../../../interfaces'; import {planningApi} from '../../../superdeskApi'; -interface IModalProps { +export interface IModalProps { onCloseModal?(item: IAssignmentOrPlanningItem): void; + unlockOnClose: boolean; } export function onItemActionModalHide( diff --git a/client/components/Main/CreateNewSubnavDropdown.tsx b/client/components/Main/CreateNewSubnavDropdown.tsx index ee7380cc6..be8d00a27 100644 --- a/client/components/Main/CreateNewSubnavDropdown.tsx +++ b/client/components/Main/CreateNewSubnavDropdown.tsx @@ -2,12 +2,14 @@ import React from 'react'; import {connect} from 'react-redux'; import {superdeskApi} from '../../superdeskApi'; -import {IEventTemplate} from '../../interfaces'; +import {ICalendar, IEventTemplate} from '../../interfaces'; import {PRIVILEGES} from '../../constants'; import * as actions from '../../actions'; -import {eventTemplates} from '../../selectors/events'; +import {eventTemplates, getRecentTemplatesSelector} from '../../selectors/events'; import {Dropdown, IDropdownItem} from '../UI/SubNav'; +import {showModal} from '@superdesk/common'; +import PlanningTemplatesModal from '../PlanningTemplatesModal/PlanningTemplatesModal'; interface IProps { addEvent(): void; @@ -16,12 +18,24 @@ interface IProps { privileges: {[key: string]: number}; createEventFromTemplate(template: IEventTemplate): void; eventTemplates: Array; + calendars: Array; + recentTemplates?: Array; } +const MORE_TEMPLATES_THRESHOLD = 5; + class CreateNewSubnavDropdownFn extends React.PureComponent { render() { const {gettext} = superdeskApi.localization; - const {addEvent, addPlanning, createPlanningOnly, privileges, createEventFromTemplate} = this.props; + const { + addEvent, + addPlanning, + createPlanningOnly, + privileges, + createEventFromTemplate, + recentTemplates, + eventTemplates + } = this.props; const items: Array = []; if (privileges[PRIVILEGES.PLANNING_MANAGEMENT]) { @@ -43,15 +57,44 @@ class CreateNewSubnavDropdownFn extends React.PureComponent { id: 'create_event', }); - this.props.eventTemplates.forEach((template) => { + /** + * Sort the templates by their name. + */ + const sortedTemplates = eventTemplates + .sort((templ1, templ2) => templ1.template_name.localeCompare(templ2.template_name)); + + const templates = recentTemplates.length === 0 ? sortedTemplates : recentTemplates; + + templates + .forEach((template) => { + items.push({ + label: template.template_name, + icon: 'icon-event icon--blue', + group: gettext('From template'), + action: () => createEventFromTemplate(template), + id: template._id, + }); + }); + + if (recentTemplates.length < MORE_TEMPLATES_THRESHOLD || + sortedTemplates.length > MORE_TEMPLATES_THRESHOLD) { items.push({ - label: template.template_name, + label: gettext('More templates...'), icon: 'icon-event icon--blue', group: gettext('From template'), - action: () => createEventFromTemplate(template), - id: template._id, + action: () => { + showModal(({closeModal}) => ( + + )); + }, + id: 'more_templates', }); - }); + } } return items.length === 0 ? null : ( @@ -79,12 +122,18 @@ class CreateNewSubnavDropdownFn extends React.PureComponent { function mapStateToProps(state) { return { + calendars: state.events.calendars, eventTemplates: eventTemplates(state), + recentTemplates: getRecentTemplatesSelector(state) }; } const mapDispatchToProps = (dispatch) => ({ - createEventFromTemplate: (template: IEventTemplate) => dispatch(actions.main.createEventFromTemplate(template)), + createEventFromTemplate: (template: IEventTemplate) => { + dispatch(actions.main.createEventFromTemplate(template)); + dispatch(actions.events.api.addEventRecentTemplate('templates', template._id)); + dispatch(actions.events.api.getEventsRecentTemplates()); + }, }); export const CreateNewSubnavDropdown = connect( diff --git a/client/components/Main/FilterSubnavDropdown.tsx b/client/components/Main/FilterSubnavDropdown.tsx index b3e33e58f..fdcb8322d 100644 --- a/client/components/Main/FilterSubnavDropdown.tsx +++ b/client/components/Main/FilterSubnavDropdown.tsx @@ -79,7 +79,10 @@ class FilterSubnavDropdownComponent extends React.PureComponent { return filters.map((filter) => ({ id: filter._id, label: filter.name, - action: () => planningApi.ui.list.changeFilterId(filter._id), + action: () => planningApi.ui.list.changeFilterId( + filter._id, + {advancedSearch: {dates: {range: filter?.params?.date_filter}}} + ), group: gettext('Search Filters'), })); } @@ -93,7 +96,10 @@ class FilterSubnavDropdownComponent extends React.PureComponent { label: this.hasGlobalFiltersPrivilege() ? gettext('All Events & Planning') : gettext('My Events & Planning'), - action: () => planningApi.ui.list.changeFilterId(EVENTS_PLANNING.FILTER.ALL_EVENTS_PLANNING), + action: () => planningApi.ui.list.changeFilterId( + EVENTS_PLANNING.FILTER.ALL_EVENTS_PLANNING, + {advancedSearch: {}} + ), group: '', } ]; @@ -107,7 +113,10 @@ class FilterSubnavDropdownComponent extends React.PureComponent { label: this.hasGlobalFiltersPrivilege() ? gettext('All Events') : gettext('My Events'), - action: () => planningApi.ui.list.changeCalendarId(EVENTS.FILTER.ALL_CALENDARS), + action: () => planningApi.ui.list.changeCalendarId( + EVENTS.FILTER.ALL_CALENDARS, + {advancedSearch: {}} + ), group: '', }, { id: 'no_calendar', @@ -163,7 +172,10 @@ class FilterSubnavDropdownComponent extends React.PureComponent { label: this.hasGlobalFiltersPrivilege() ? gettext('All Planning Items') : gettext('My Planning'), - action: () => planningApi.ui.list.changeAgendaId(AGENDA.FILTER.ALL_PLANNING), + action: () => planningApi.ui.list.changeAgendaId( + AGENDA.FILTER.ALL_PLANNING, + {advancedSearch: {}} + ), group: '', }, { id: 'no_agenda', diff --git a/client/components/Main/FiltersBox.tsx b/client/components/Main/FiltersBox.tsx index 98eeadaea..a21719f51 100644 --- a/client/components/Main/FiltersBox.tsx +++ b/client/components/Main/FiltersBox.tsx @@ -7,20 +7,29 @@ import {StretchBar} from '../UI/SubNav'; import {PLANNING_VIEW} from '../../interfaces'; import {activeFilter as getCurrentView} from '../../selectors/main'; -import {planningApi} from '../../superdeskApi'; +import {planningApi, superdeskApi} from '../../superdeskApi'; import {PRIVILEGES} from '../../constants'; +import * as selectors from '../../selectors'; interface IProps { showFilters?: boolean; // defaults to true currentView: PLANNING_VIEW; privileges: {[key: string]: number}; + currentFilterId?: any; } const mapStateToProps = (state) => ({ currentView: getCurrentView(state), + currentFilterId: selectors.main.currentSearchFilterId(state), }); class FiltersBoxComponent extends React.PureComponent { + componentDidUpdate(): void { + const {urlParams} = superdeskApi.browser.location; + + urlParams.setString('eventsPlanningFilter', this.props.currentFilterId); + } + render() { const privileges = this.props.privileges; let filter_items = []; diff --git a/client/components/Main/ItemEditor/Editor.tsx b/client/components/Main/ItemEditor/Editor.tsx index 3d5997db0..a125c063e 100644 --- a/client/components/Main/ItemEditor/Editor.tsx +++ b/client/components/Main/ItemEditor/Editor.tsx @@ -225,22 +225,24 @@ export class EditorComponent extends React.Component }; const onSave = (isKilled || hasErrors) ? null : - (withConfirmation, updateMethod) => ( + (withConfirmation, updateMethod, planningUpdateMethods) => ( this.itemManager.save( withConfirmation, - updateMethod, + {name: updateMethod, value: updateMethod}, true, - updateStates + updateStates, + planningUpdateMethods ) ); const onSaveAndPost = (!isKilled || hasErrors) ? null : - (withConfirmation, updateMethod) => ( + (withConfirmation, updateMethod, planningUpdateMethods) => ( this.itemManager.saveAndPost( withConfirmation, updateMethod, true, - updateStates + updateStates, + planningUpdateMethods ) ); diff --git a/client/components/Main/ItemEditor/EditorHeader.tsx b/client/components/Main/ItemEditor/EditorHeader.tsx index 2874078aa..62fd15662 100644 --- a/client/components/Main/ItemEditor/EditorHeader.tsx +++ b/client/components/Main/ItemEditor/EditorHeader.tsx @@ -395,6 +395,7 @@ export class EditorHeader extends React.Component { loading, itemManager, autoSave, + diff, } = this.props; const states = this.getItemStates(); @@ -428,7 +429,7 @@ export class EditorHeader extends React.Component { {!loading && !hideItemActions && ( { () => { const message = gettext('Save changes before creating a template?'); - dispatch(allActions.main.openActionModalFromEditor(item, message, (updatedItem) => { - dispatch(eventsApi.createEventTemplate(updatedItem._id)); + dispatch(allActions.main.openActionModalFromEditor(item, message, () => { + dispatch(eventsApi.createEventTemplate(item)); })); }, [EVENTS.ITEM_ACTIONS.MARK_AS_COMPLETED.actionName]: diff --git a/client/components/Main/ItemEditor/ItemManager.ts b/client/components/Main/ItemEditor/ItemManager.ts index 195dc6e83..ee91f7178 100644 --- a/client/components/Main/ItemEditor/ItemManager.ts +++ b/client/components/Main/ItemEditor/ItemManager.ts @@ -566,7 +566,8 @@ export class ItemManager { withConfirmation = true, updateMethod = EVENTS.UPDATE_METHODS[0], closeAfter = false, - updateStates = true + updateStates = true, + planningUpdateMethods = {} ) { return this._save({ post: false, @@ -575,6 +576,7 @@ export class ItemManager { updateMethod: updateMethod, closeAfter: closeAfter || this.shouldClose(), updateStates: updateStates, + planningUpdateMethods: planningUpdateMethods, }); } @@ -582,7 +584,8 @@ export class ItemManager { withConfirmation = true, updateMethod = EVENTS.UPDATE_METHODS[0], closeAfter = false, - updateStates = true + updateStates = true, + planningUpdateMethods = {} ) { return this._save({ post: true, @@ -591,6 +594,7 @@ export class ItemManager { updateMethod: updateMethod, closeAfter: closeAfter || this.shouldClose(), updateStates: updateStates, + planningUpdateMethods: planningUpdateMethods, }); } @@ -644,6 +648,7 @@ export class ItemManager { updateMethod = EVENTS.UPDATE_METHODS[0], closeAfter = false, updateStates = true, + planningUpdateMethods = {} } = {}) { if (!isEqual(this.state.errorMessages, [])) { return this.setState({ @@ -684,8 +689,16 @@ export class ItemManager { updates.pubstatus = POST_STATE.CANCELLED; } - if (this.props.itemType === ITEM_TYPE.EVENT) { + if (updates.type === 'event') { updates.update_method = updateMethod; + + if (Object.keys(planningUpdateMethods).length > 0) { + updates.associated_plannings?.forEach((planningItem) => { + if (planningUpdateMethods[planningItem._id] != null) { + planningItem.update_method = planningUpdateMethods[planningItem._id]; + } + }); + } } return promise.then(() => this.autoSave.flushAutosave()) diff --git a/client/components/Main/ItemEditor/tests/ItemManager_test.ts b/client/components/Main/ItemEditor/tests/ItemManager_test.ts index 2b83eba8b..d9d36b7b0 100644 --- a/client/components/Main/ItemEditor/tests/ItemManager_test.ts +++ b/client/components/Main/ItemEditor/tests/ItemManager_test.ts @@ -1391,6 +1391,7 @@ describe('components.Main.ItemManager', () => { updateMethod: EVENTS.UPDATE_METHODS[0], closeAfter: false, updateStates: true, + planningUpdateMethods: {}, }]); }); @@ -1404,6 +1405,7 @@ describe('components.Main.ItemManager', () => { updateMethod: EVENTS.UPDATE_METHODS[0], closeAfter: false, updateStates: true, + planningUpdateMethods: {}, }]); }); diff --git a/client/components/Main/ListGroup.tsx b/client/components/Main/ListGroup.tsx index d3cbac9df..6cb144717 100644 --- a/client/components/Main/ListGroup.tsx +++ b/client/components/Main/ListGroup.tsx @@ -2,7 +2,11 @@ import React from 'react'; import moment from 'moment-timezone'; import {ListGroupItem} from './'; import {Group, Header} from '../UI/List'; -import {ICommonAdvancedSearchParams, IEventOrPlanningItem, LIST_VIEW_TYPE, SORT_FIELD} from '../../interfaces'; +import { + ICommonAdvancedSearchParams, + IEventOrPlanningItem, ISearchFilter, + LIST_VIEW_TYPE, SORT_FIELD +} from '../../interfaces'; import {timeUtils} from '../../utils'; const TIME_COLUMN_MIN_WIDTH = { @@ -78,7 +82,8 @@ interface IProps { listViewType?: string; sortField?: string; listBoxGroupProps: {}; - searchParams:ICommonAdvancedSearchParams; + searchParams?: ICommonAdvancedSearchParams; + searchFilterParams?: ISearchFilter['params']; } export class ListGroup extends React.Component { @@ -147,6 +152,7 @@ export class ListGroup extends React.Component { sortField, listBoxGroupProps, searchParams, + searchFilterParams, } = this.props; // with defaults @@ -208,6 +214,7 @@ export class ListGroup extends React.Component { sortField: sortField, minTimeWidth: minTimeWidth, searchParams: searchParams, + searchFilterParams: searchFilterParams, }; if (indexItems) { diff --git a/client/components/Main/ListGroupItem.tsx b/client/components/Main/ListGroupItem.tsx index 5290de171..7224f1196 100644 --- a/client/components/Main/ListGroupItem.tsx +++ b/client/components/Main/ListGroupItem.tsx @@ -4,7 +4,7 @@ import { IEventListItemProps, IPlanningListItemProps, IEventOrPlanningItem, - IEventItem, IPlanningItem, IBaseListItemProps, ICommonAdvancedSearchParams + IEventItem, IPlanningItem, IBaseListItemProps, ICommonAdvancedSearchParams, ISearchFilter } from '../../interfaces'; import {EventItem, EventItemWithPlanning} from '../Events'; @@ -12,6 +12,7 @@ import {PlanningItem} from '../Planning'; import {ITEM_TYPE, EVENTS, PLANNING, MAIN, CLICK_DELAY} from '../../constants'; import {getItemType, eventUtils} from '../../utils'; +import {planningApi} from '../../superdeskApi'; interface IProps extends Omit< IEventListItemProps & IPlanningListItemProps, @@ -27,6 +28,7 @@ interface IProps extends Omit< navigateDown?: boolean; minTimeWidth?: string; searchParams?: ICommonAdvancedSearchParams; + searchFilterParams?: ISearchFilter['params']; onDoubleClick(item: IEventOrPlanningItem): void; showRelatedPlannings(item: IEventItem): void; @@ -123,6 +125,7 @@ export class ListGroupItem extends React.Component { sortField, minTimeWidth, searchParams, + searchFilterParams, } = this.props; const itemType = getItemType(item); @@ -152,7 +155,7 @@ export class ListGroupItem extends React.Component { ...itemProps, item: item as IEventItem, calendars: calendars, - filterLanguage: searchParams?.language, + filterLanguage: searchParams?.language || searchFilterParams?.language, multiSelected: indexOf(selectedEventIds, item._id) !== -1, [EVENTS.ITEM_ACTIONS.EDIT_EVENT.actionName]: itemActions[EVENTS.ITEM_ACTIONS.EDIT_EVENT.actionName], @@ -195,7 +198,8 @@ export class ListGroupItem extends React.Component { contentTypes: contentTypes, agendas: agendas, date: date, - filterLanguage: searchParams?.language, + filterLanguage: searchParams?.language || searchFilterParams?.language, + isAgendaEnabled: planningApi.planning.getEditorProfile().editor.agendas.enabled, onAddCoverageClick: onAddCoverageClick, multiSelected: indexOf(selectedPlanningIds, item._id) !== -1, showAddCoverage: showAddCoverage, diff --git a/client/components/Main/ListPanel.tsx b/client/components/Main/ListPanel.tsx index 4b503e73e..b53405661 100644 --- a/client/components/Main/ListPanel.tsx +++ b/client/components/Main/ListPanel.tsx @@ -10,6 +10,7 @@ import { IEventOrPlanningItem, IG2ContentType, ILockedItems, IPlanningItem, + ISearchFilter, ISession, LIST_VIEW_TYPE, SORT_FIELD } from '../../interfaces'; @@ -68,7 +69,8 @@ interface IProps { listViewType: LIST_VIEW_TYPE; sortField: SORT_FIELD; userInitiatedSearch?: boolean; - searchParams?: ICommonAdvancedSearchParams + searchParams?: ICommonAdvancedSearchParams, + searchFilterParams?: ISearchFilter['params'] onItemClick(item: IEventOrPlanningItem): void; onDoubleClick(item: IEventOrPlanningItem): void; @@ -319,7 +321,8 @@ export class ListPanel extends React.Component { contacts, listViewType, sortField, - searchParams + searchParams, + searchFilterParams, } = this.props; let indexFrom = 0; @@ -399,6 +402,7 @@ export class ListPanel extends React.Component { sortField: sortField, listBoxGroupProps: listBoxGroupProps, searchParams: searchParams, + searchFilterParams: searchFilterParams, ...propsForNestedListItems, }; @@ -418,18 +422,16 @@ export class ListPanel extends React.Component { /> ); })} - {!isAllListItemsLoaded && ( + {(!isAllListItemsLoaded && loadingIndicator) && (
    - - -
    - {gettext('loading more items...')} -
    -
    -
    + +
    + {gettext('loading more items...')} +
    +
    )} diff --git a/client/components/ModalWithForm/index.tsx b/client/components/ModalWithForm/index.tsx index 3b409eea4..361092326 100644 --- a/client/components/ModalWithForm/index.tsx +++ b/client/components/ModalWithForm/index.tsx @@ -101,6 +101,7 @@ export class ModalWithForm extends React.Component { fill={fill} fullscreen={fullscreen} white={white} + removeTabIndexAttribute={true} >

    {title}

    diff --git a/client/components/MultiSelectActions.tsx b/client/components/MultiSelectActions.tsx index c02509e14..df78c835d 100644 --- a/client/components/MultiSelectActions.tsx +++ b/client/components/MultiSelectActions.tsx @@ -88,12 +88,24 @@ export class MultiSelectActionsComponent extends React.PureComponent { let tools = []; + if (!some(selectedPlannings, 'lock_action')) { + tools.push( + - - ))} - - {users.length === 0 && ( -
  • - -
  • - )} - - - - ); - } -} - -SelectUserPopup.propTypes = { - onClose: PropTypes.func, - target: PropTypes.string, - popupContainer: PropTypes.func, - users: PropTypes.array, - onChange: PropTypes.func, -}; diff --git a/client/components/UI/Form/SelectUserInput/index.tsx b/client/components/UI/Form/SelectUserInput/index.tsx deleted file mode 100644 index 7a6307eae..000000000 --- a/client/components/UI/Form/SelectUserInput/index.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import React from 'react'; - -import {IUser} from 'superdesk-api'; -import {superdeskApi} from '../../../../superdeskApi'; -import {KEYCODES} from '../../constants'; - -import {onEventCapture} from '../../utils'; - -import {Row, LineInput, Label, Input} from '../'; -import {UserAvatarWithMargin} from '../../../../components/UserAvatar'; -import {SelectUserPopup} from './SelectUserPopup'; - -interface IProps { - field: string; - label?: string; - placeholder?: string; - value: any; - users: Array; - readOnly?: boolean; - hideInactiveDisabled?: boolean; // defaults to `true` - inline?: boolean; - onChange(field: string, value?: IUser): void; - popupContainer?(): HTMLElement; -} - -interface IState { - filteredUserList: Array; - searchText?: string; - openFilterList?: boolean; -} - -/** - * @ngdoc react - * @name SelectUserInput - * @description Component to select users from a list - */ -export class SelectUserInput extends React.Component { - constructor(props) { - super(props); - - this.state = { - filteredUserList: this.getUsersToDisplay(this.props.users), - searchText: '', - openFilterList: false, - }; - - this.openPopup = this.openPopup.bind(this); - this.closePopup = this.closePopup.bind(this); - this.filterUserList = this.filterUserList.bind(this); - this.onUserChange = this.onUserChange.bind(this); - } - - componentWillReceiveProps(nextProps) { - this.setState({filteredUserList: this.getUsersToDisplay(nextProps.users)}); - } - - openPopup() { - this.setState({openFilterList: true}); - } - - closePopup() { - this.setState({openFilterList: false}); - } - - filterUserList(field, value) { - if (!value) { - this.setState({ - filteredUserList: this.getUsersToDisplay(this.props.users), - searchText: '', - openFilterList: true, - }); - return; - } - - const filterTextNoCase = value.toLowerCase(); - const newUserList = this.props.users.filter((user) => ( - user.display_name.toLowerCase().substr(0, value.length) === filterTextNoCase || - user.display_name.toLowerCase().indexOf(filterTextNoCase) >= 0 - )); - - this.setState({ - filteredUserList: this.getUsersToDisplay(newUserList), - searchText: value, - openFilterList: true, - }); - } - - onUserChange(newUserId) { - this.props.onChange(this.props.field, newUserId); - this.setState({ - openFilterList: false, - searchText: '', - }); - } - - getUsersToDisplay(list = []) { - if (!(this.props.hideInactiveDisabled ?? true)) { - return list; - } else { - return list.filter((u) => u.is_active && u.is_enabled && !u.needs_activation); - } - } - - render() { - const {gettext} = superdeskApi.localization; - const {value, popupContainer, label, readOnly, inline, field} = this.props; - - return ( -
    - {(!inline || value) && ( - - - - - )} - - {!readOnly && (!inline || !value) && ( - - - { - if (event.keyCode === KEYCODES.ENTER || - event.keyCode === KEYCODES.DOWN) { - onEventCapture(event); - this.openPopup(); - } - } - } - /> - - {this.state.openFilterList && ( - - )} - - - )} -
    - ); - } -} diff --git a/client/components/UI/Form/SelectUserInput/style.scss b/client/components/UI/Form/SelectUserInput/style.scss deleted file mode 100644 index cc39f1de7..000000000 --- a/client/components/UI/Form/SelectUserInput/style.scss +++ /dev/null @@ -1,48 +0,0 @@ -@import '~superdesk-ui-framework/app/styles/_mixins.scss'; -@import '~superdesk-ui-framework/app/styles/_variables.scss'; - -.user-search__popup { - margin-top: 1px; - - &-list { - li { - margin: 5px 0; - } - - overflow-y: scroll; - max-height: 250px; - } - - &-item { - margin: 5px; - &:hover { - background: $sd-hover; - } - - button { - width: 100%; - text-align: left; - } - - &--active { - background: $sd-hover; - } - } - - &-item-label { - padding-top: 5px; - display: inline-block; - width: 200px; - text-align: left; - } - - &-user { - display: inline-block; - margin: 10px 0; - width: 100%; - - button { - float: right; - } - } -} diff --git a/client/components/UI/Form/index.ts b/client/components/UI/Form/index.ts index 03a068922..2d0a9c973 100644 --- a/client/components/UI/Form/index.ts +++ b/client/components/UI/Form/index.ts @@ -14,7 +14,6 @@ export {SelectInput} from './SelectInput'; export {FileInput} from './FileInput'; export {LinkInput} from './LinkInput'; export {SelectMetaTermsInput} from './SelectMetaTermsInput/index'; -export {SelectUserInput} from './SelectUserInput'; export {SelectTagInput} from './SelectTagInput'; export {Checkbox} from './Checkbox'; export {CheckboxGroup} from './CheckboxGroup'; diff --git a/client/components/UI/List/Column.tsx b/client/components/UI/List/Column.tsx index 3eac2e008..c8f9e0d1e 100644 --- a/client/components/UI/List/Column.tsx +++ b/client/components/UI/List/Column.tsx @@ -7,7 +7,26 @@ import classNames from 'classnames'; * @name Column * @description Column Component of a list item */ -export const Column = ({children, grow, border, noPadding, hasCheck, checked, className}) => ( + +interface IProps { + children: Array | JSX.Element; + grow?: boolean; + border?: boolean; + noPadding?: boolean; + hasCheck?: boolean; + checked?: boolean; + className?: string; +} + +export const Column = ({ + children, + grow = false, + border = true, + noPadding = false, + hasCheck = false, + checked, + className, +}: IProps) => (
    ); - -Column.propTypes = { - children: PropTypes.node.isRequired, - grow: PropTypes.bool, - border: PropTypes.bool, - noPadding: PropTypes.bool, - hasCheck: PropTypes.bool, - checked: PropTypes.bool, - className: PropTypes.string, -}; - -Column.defaultProps = { - grow: false, - border: true, - noPadding: false, - hasCheck: false, -}; diff --git a/client/components/UI/List/Header.tsx b/client/components/UI/List/Header.tsx index 96d871ad0..e629c7983 100644 --- a/client/components/UI/List/Header.tsx +++ b/client/components/UI/List/Header.tsx @@ -1,13 +1,19 @@ import React from 'react'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; +interface IProps { + title?: string; + id?: string; + marginTop?: boolean; + marginBottom?: boolean; + children?: React.ReactNode; +} /** * @ngdoc react * @name Header * @description Header Component of a list */ -export const Header = ({children, title, marginTop, marginBottom, id}) => ( +export const Header = ({children, title, marginTop, marginBottom, id}: IProps) => (
    ( {children}
    ); - -Header.propTypes = { - title: PropTypes.string, - id: PropTypes.string, - marginTop: PropTypes.bool, - marginBottom: PropTypes.bool, - children: PropTypes.node, -}; diff --git a/client/components/UI/List/Row.tsx b/client/components/UI/List/Row.tsx index f9b3581e5..f9a3cb5ff 100644 --- a/client/components/UI/List/Row.tsx +++ b/client/components/UI/List/Row.tsx @@ -1,13 +1,21 @@ -import React from 'react'; +import React, {CSSProperties} from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +interface IProps { + children: Array | JSX.Element; + classes?: string; + paddingBottom?: boolean; + testId?: string; + style?: CSSProperties; +} + /** * @ngdoc react * @name Row * @description Row Component in a list of item where each item is a row */ -export const Row = ({children, classes, paddingBottom, testId, style}) => ( +export const Row = ({children, classes = '', paddingBottom, testId, style}: IProps) => (
    ( {children}
    ); - -Row.propTypes = { - children: PropTypes.node.isRequired, - classes: PropTypes.string, - margin: PropTypes.bool, - marginTop: PropTypes.bool, - paddingBottom: PropTypes.bool, - testId: PropTypes.string, - style: PropTypes.object, -}; - -Row.defaultProps = { - classes: '', - margin: true, - marginTop: false, -}; diff --git a/client/components/UI/Popup/Content.tsx b/client/components/UI/Popup/Content.tsx index ee4f10a6c..76c7c778d 100644 --- a/client/components/UI/Popup/Content.tsx +++ b/client/components/UI/Popup/Content.tsx @@ -2,12 +2,17 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +interface IProps { + children?: React.ReactNode; + className?: string; + noPadding?: boolean; +} /** * @ngdoc react * @name Content * @description Component to hold contents of a popup */ -const Content = ({children, className, noPadding}) => ( +const Content = ({children, className, noPadding}: IProps) => (
    (
    ); -Content.propTypes = { - children: PropTypes.node, - className: PropTypes.string, - noPadding: PropTypes.bool, -}; - -Content.defaultProps = {noPadding: false}; - export default Content; diff --git a/client/components/UI/SubNav/SlidingToolBar.tsx b/client/components/UI/SubNav/SlidingToolBar.tsx index 1490a0914..54ea9bb40 100644 --- a/client/components/UI/SubNav/SlidingToolBar.tsx +++ b/client/components/UI/SubNav/SlidingToolBar.tsx @@ -1,13 +1,22 @@ import React from 'react'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; import {superdeskApi} from '../../../superdeskApi'; import {Button} from '../index'; -import {gettext} from '../utils'; import './style.scss'; +interface IProps { + hide: boolean; + onCancel: () => void; + innerInfo: string; + innerTools: React.ReactNode; + tools: React.ReactNode; + rightCancelButton?: boolean; + cancelText?: string; + bulkAddToWorkflow: () => void; +} + /** * @ngdoc react * @name SlidingToolBar @@ -21,14 +30,15 @@ export const SlidingToolBar = ({ onCancel, rightCancelButton, cancelText, -}) => { +}: IProps) => { const {gettext} = superdeskApi.localization; + const hideDefault = hide ?? true; return (
    {!rightCancelButton &&