superdesk/superdesk-client-core

View on GitHub
scripts/apps/monitoring/directives/MonitoringGroup.ts

Summary

Maintainability
F
1 wk
Test Coverage
/* eslint-disable complexity */
import _, {debounce} from 'lodash';
import getCustomSortForGroup, {GroupSortOptions} from '../helpers/CustomSortOfGroups';
import {GET_LABEL_MAP, getLabelForStage} from '../../workspace/content/constants';
import {isPublished} from 'apps/archive/utils';
import {AuthoringWorkspaceService} from 'apps/authoring/authoring/services/AuthoringWorkspaceService';
import {DESK_OUTPUT} from 'apps/desks/constants';
import {appConfig} from 'appConfig';
import {IMonitoringFilter, IRestApiResponse, IArticle} from 'superdesk-api';
import {getExtensionSections} from '../services/CardsService';
import {showRefresh} from 'apps/search/services/SearchService';

function translateCustomSorts(customSorts: GroupSortOptions) {
    const translated = {};

    const translatedFields = GET_LABEL_MAP();

    for (let field of customSorts) {
        translated[field] = {label: translatedFields[field]};
    }

    return translated;
}

export type StageGroup = {
    _id?: any;
    type?: 'search' | string;
    search?: 'ingest';
    max_items?: any;
    customFilters?: string; // JSON string for {[key: string]: IMonitoringFilter}
};

interface IScope extends ng.IScope {
    customDataSource: {
        getItems(from: number, pageSize: number): any;
        getItem(item: any): any;
    };
    page: number;
    fetching: boolean;
    previewingBroadcast: boolean;
    loading: boolean;
    cacheNextItems: Array<any>;
    cachePreviousItems: Array<any>;
    viewColumn: any;
    labelForStage: typeof getLabelForStage;
    styleProperties: any;
    edit: any;
    select: any;
    preview: any;
    showRefresh: boolean;
    viewSingleGroup: any;
    forceLimited: any;
    total: any;
    items: any;
    selected: any;
    numItems: any;
    view: any;
    group?: StageGroup;
    viewType?: any;
    currentGroup: any;
    fetchNext(i: number): any;
    refreshGroup(): void;
    customSortOptions: { [field: string]: { label: string } };
    customSortOptionActive?: { field: string, order: 'asc' | 'desc' };

    hideActionsForMonitoringItems: boolean;
    disableMonitoringMultiSelect: boolean;
    onMonitoringItemSelect(): void;
    onMonitoringItemDoubleClick(): void;
    selectDefaultSortOption(): void;
    selectCustomSortOption(field: string): void;
    toggleCustomSortOrder(): void;
}

/**
 * @ngdoc directive
 * @module superdesk.apps.monitoring
 * @name sdMonitoringGroup

 * @description
 *   A directive that generates group/section on stages of a desk or saved search.
 */
MonitoringGroup.$inject = [
    'cards',
    'api',
    'authoringWorkspace',
    '$timeout',
    'superdesk',
    'session',
    'activityService',
    'desks',
    'search',
    'multi',
    '$rootScope',
];
export function MonitoringGroup(
    cards,
    api,
    authoringWorkspace: AuthoringWorkspaceService,
    $timeout,
    superdesk,
    session,
    activityService,
    desks,
    search,
    multi,
    $rootScope,
) {
    let ITEM_HEIGHT = 57;
    let PAGE_SIZE = 25;
    let DEFAULT_GROUP_ITEMS = 10;

    return {
        templateUrl: 'scripts/apps/monitoring/views/monitoring-group.html',
        require: ['^sdMonitoringView'],
        scope: {
            // not required if using custom data source
            group: '=?',
            view: '=?',

            numItems: '=',
            viewType: '=',
            forceLimited: '@',

            customDataSource: '=?',
            hideActionsForMonitoringItems: '=?',
            disableMonitoringMultiSelect: '=?',
            onMonitoringItemSelect: '=?',
            onMonitoringItemDoubleClick: '=?',
        },
        link: function(scope: IScope, elem, attrs, ctrls) {
            if (scope.group == null) {
                scope.group = {};
            }

            if (
                scope.customDataSource != null
                && typeof scope.customDataSource === 'object'
                && typeof scope.customDataSource.getItem !== typeof scope.customDataSource.getItems
            ) {
                throw new Error(
                    'Both values have to be either supplied or not. Supplying only one of them is not supported.',
                );
            }

            let queryPromise = null;
            // Using debounce to run it only once for multiple events
            // happening at around same time, plus wait for query to finish
            // before sending another query so if server is slow to respond
            // it will slow down requests.
            const scheduleQuery = debounce((event, data) => {
                if (queryPromise == null) {
                    queryPromise = queryItems(event, data, {auto: (data && data.force) ? 0 : 1})
                        .finally(() => {
                            queryPromise = null;
                            scope.$applyAsync();
                        });
                } else {
                    // There is already request pending so queue another one to run when it finishes.
                    // It's using debounce inside so if there are more those will be ingored.
                    queryPromise.then(() => scheduleQuery(event, data));
                }
            }, 1000, {maxWait: 3000});

            var monitoring = ctrls[0];
            var projections = search.getProjectedFields();

            var containerElem = monitoring.viewColumn ? $(document).find('.content-list') : elem.find('.stage-content');

            ITEM_HEIGHT = search.singleLine ? 29 : 57;

            const customSorts = getCustomSortForGroup(scope.group);

            if (customSorts != null) {
                scope.customSortOptions = translateCustomSorts(customSorts.allowed_fields_to_sort);
                if (customSorts.default) {
                    scope.customSortOptionActive = {
                        field: customSorts.default.field,
                        order: customSorts.default.order,
                    };
                } else {
                    scope.customSortOptionActive = null;
                }
            }

            scope.page = 1;
            scope.fetching = false;
            scope.previewingBroadcast = false;
            scope.loading = false;
            scope.cacheNextItems = [];
            scope.cachePreviousItems = [];
            scope.viewColumn = monitoring.viewColumn;

            scope.$on('view:column', (_event, data) => {
                scope.$applyAsync(() => {
                    scope.viewColumn = data.viewColumn;
                });
            });

            scope.labelForStage = getLabelForStage;

            scope.styleProperties = {};

            scope.edit = edit;
            scope.select = select;
            scope.preview = preview;
            scope.viewSingleGroup = viewSingleGroup;

            scope.$watchCollection('group', () => {
                updateGroupStyle();
                scope.refreshGroup();
            });

            scope.$on('task:stage', scheduleQuery);
            scope.$on('item:spike', scheduleIfShouldUpdate);
            scope.$on('item:copy', scheduleQuery);
            scope.$on('item:unlink', scheduleQuery);
            scope.$on('item:correction', scheduleQuery);
            scope.$on('item:duplicate', scheduleQuery);
            scope.$on('item:translate', scheduleQuery);
            scope.$on('broadcast:created', (event, args) => {
                scope.previewingBroadcast = true;
                queryItems();
                preview(args.item);
            });
            scope.$on('item:unspike', scheduleIfShouldUpdate);
            scope.$on('$routeUpdate', (event, data) => {
                if (scope.viewColumn) {
                    updateGroupStyle();
                }
            });
            scope.$on('broadcast:preview', (event, args) => {
                scope.previewingBroadcast = true;
                if (!_.isNil(args.item)) {
                    preview(args.item);
                } else {
                    monitoring.closePreview();
                }
            });

            scope.$on('item:highlights', scheduleQuery);
            scope.$on('item:marked_desks', scheduleQuery);
            scope.$on('content:update', scheduleIfShouldUpdate);

            if (scope.group.type === 'search' && search.doesSearchAgainstRepo(scope.group.search, 'ingest')) {
                scope.$on('ingest:update', (event, data) => {
                    if (!scope.showRefresh) {
                        scheduleQuery(event, data);
                    }
                });
            }

            // Determines if limited maxHeight style need to apply on group list
            function shouldLimited() {
                if (scope.customDataSource != null) {
                    return false;
                }

                let limited = !(monitoring.singleGroup || scope.group.type === 'highlights'
                || scope.group.type === 'spike' || scope.group.type === 'personal');

                if (!_.isNil(scope.forceLimited)) {
                    limited = JSON.parse(scope.forceLimited);
                }

                return limited;
            }

            function scheduleIfShouldUpdate(event, data) {
                if (data && data.from_stage && data.from_stage === scope.group._id) {
                    // item was moved from current stage
                    extendItem(data.item, {
                        gone: true,
                        _etag: data.from_stage, // this must change to make it re-render
                    });
                    scheduleQuery(event, data);
                } else if (scope.group.type === DESK_OUTPUT && data &&
                     scope.group._id === _.get(data, 'from_desk') + ':output') {
                    // item was moved to production desk, therefore it should comes in from_desk's output stage too
                    scheduleQuery(event, data);
                } else if (data && data.item && _.includes(['item:spike', 'item:unspike'], event.name)) {
                    // item was spiked/unspiked from the list
                    if (scope.group.type === DESK_OUTPUT) {
                        scheduleQuery(event, data);
                    } else {
                        extendItem(data.item, {
                            gone: true,
                            _etag: data.item,
                        });
                        scheduleQuery(event, data);
                    }
                } else if (data && data.to_stage && data.to_stage === scope.group._id) {
                    // new item in current stage
                    scheduleQuery(event, data);
                } else if (data && cards.shouldUpdate(scope.group, data)) {
                    scheduleQuery(event, data);
                } else if (data?.items != null) {
                    for (const updatedItemGuid in data.items) {
                        // check if stage contains updated items
                        if (
                            !!data.items[updatedItemGuid] &&
                            scope.items._items.find((item) => item.guid === updatedItemGuid)
                        ) {
                            scheduleQuery(event, data);
                            break;
                        }
                    }
                }
            }

            function extendItem(itemId, updates) {
                scope.$apply(() => {
                    scope.items._items = scope.items._items.map((item) => {
                        if (item._id === itemId) {
                            return angular.extend(item, updates);
                        }

                        return item;
                    });

                    scope.items = angular.extend({}, scope.items); // trigger a watch
                });
            }

            scope.$on('item:fetch', scheduleIfShouldUpdate);
            scope.$on('item:move', scheduleIfShouldUpdate);

            scope.$watch('selected', (newVal, oldVal) => {
                if (!newVal && scope.previewingBroadcast) {
                    scope.previewingBroadcast = false;
                }
            });

            /*
             * Change between single stage/desk view and monitoring grouped view
             *
             * @param {string} type - type is 'desk' or 'stage' to switch single view
             */
            function toggleMonitoringSingleView(type) {
                if (_.isNil(monitoring.singleGroup) && scope.selected) {
                    scope.$applyAsync(() => {
                        monitoring.viewSingleGroup(monitoring.selectedGroup, type);
                    });
                }

                // Returns back to monitoring view from single view
                if (monitoring.singleGroup) {
                    scope.$applyAsync(() => {
                        monitoring.viewMonitoringHome();
                    });
                }
            }

            /*
             * Change between single stage view and grouped view by keyboard
             * Keyboard shortcut: Ctrl + alt + j
             */
            scope.$on('key:ctrl:alt:j', (event, data) => {
                toggleMonitoringSingleView('stage');
            });

            // refreshes the list for matching group or view type only or if swimlane view is ON.
            scope.$on('refresh:list', (event, group, options) => {
                /**
                 * When manual refreshing is enabled, scrolling should not automatically refresh the list.
                 */
                if (scope.showRefresh === true && options?.event_origin === 'scroll') {
                    return;
                }

                const currentScope: IScope = event.currentScope as IScope;
                const _viewType = currentScope.viewType || '';
                const viewTypeMatches = [
                    'highlights',
                    'spiked',
                    'single_monitoring',
                    'monitoring',
                    DESK_OUTPUT,
                    'personal',
                ].includes(_viewType);

                if ((group && group._id === scope.group._id) || (!group && viewTypeMatches)) {
                    scope.refreshGroup();
                }
            });

            scope.$on('render:next', (event) => {
                scope.$applyAsync(() => {
                    if (scope.items) {
                        scope.fetchNext(scope.items._items.length);
                    }
                });
            });

            /*
             * Change between single desk view and grouped view by keyboard
             * Keyboard shortcut: Ctrl + alt + g
             */
            scope.$on('key:ctrl:alt:g', () => {
                toggleMonitoringSingleView('desk');
            });

            if (['highlights', 'spiked', 'personal'].includes(scope.viewType)) {
                $rootScope.$broadcast('stage:single');
            }

            // forced refresh on refresh button click or on refresh:list
            scope.refreshGroup = function() {
                monitoring.showRefresh = scope.showRefresh = false;
                scheduleQuery(null, {force: true});
                // update scroll position to top when forced refresh
                if (containerElem[0].scrollTop > 0) {
                    containerElem[0].scrollTop = 0;
                }
            };

            function updateGroupStyle() {
                scope.styleProperties.maxHeight = null;
                if (scope.viewColumn) {
                    // maxHeight is not applicable for swimlane/column view, as each stages/column
                    // don't need to have scroll bars because container scroll bar of monitoring
                    // view will serve scrolling
                    $rootScope.$broadcast('resize:header');
                } else if (shouldLimited()) {
                    let groupItems = scope.group.max_items || DEFAULT_GROUP_ITEMS;
                    let scrollOffset = 0;

                    if (groupItems === PAGE_SIZE) {
                        scrollOffset = Math.round(ITEM_HEIGHT / 2);
                    }

                    scope.styleProperties.maxHeight = groupItems * ITEM_HEIGHT - scrollOffset;
                }
            }

            var criteria;

            function edit(item) {
                if (item.state !== 'spiked') {
                    var intent = {action: 'list', type: undefined};

                    if (item._type === 'ingest') {
                        intent.type = 'ingest';
                        fetchAndEdit(intent, item, 'archive');
                    } else if (item._type === 'externalsource') {
                        intent.type = 'externalsource';
                        fetchAndEdit(intent, item, 'externalsource');
                    } else if (isPublished(item)) {
                        authoringWorkspace.view(item);
                    } else {
                        authoringWorkspace.edit(item);
                    }
                }
            }

            /**
             * Perform fetch for an item via activity and then edit fetched item
             *
             * @param {Object} intent
             * @param {Object} item
             * @param {String} activityId
             */
            function fetchAndEdit(intent, item, activityId) {
                let activity = _.find(superdesk.findActivities(intent, item), {_id: activityId});

                if (!_.isNil(activity)) {
                    activityService.start(activity, {data: {item: item}})
                        .then((_item) => {
                            authoringWorkspace.edit(_item);
                        });
                }
            }

            function select(item) {
                scope.currentGroup = item.task ? item.task.stage : null;
                scope.selected = item;
                monitoring.selectedGroup = scope.group;
                monitoring.preview(item);
            }

            function preview(item) {
                if (item) {
                    // for items from external source or if type is undefined.
                    if (item._type === 'externalsource') {
                        select(item);
                        return;
                    }

                    scope.loading = true;

                    (function() {
                        if (scope.customDataSource != null && typeof scope.customDataSource.getItem === 'function') {
                            return scope.customDataSource.getItem(item);
                        } else {
                            criteria = cards.criteria(scope.group, null, monitoring.queryParam);
                            let previewCriteria = search.getSingleItemCriteria(item, criteria);

                            return apiquery(previewCriteria, false);
                        }
                    })()
                        .then((completeItems) => {
                            let completeItem = search.mergeHighlightFields(completeItems._items[0]);

                            select(completeItem);
                        })
                        .finally(() => {
                            scope.loading = false;

                            // Reset to get bulk query items
                            criteria = cards.criteria(scope.group, null, monitoring.queryParam);
                            criteria.source.size = PAGE_SIZE;
                        });
                } else {
                    select(item);
                }

                if (scope.viewColumn) {
                    updateGroupStyle();
                }
            }

            // For highlight page return only highlights items, i.e, include only last version if item type is published
            function getOnlyHighlightsItems(items) {
                items._items = _.filter(items._items, (item) =>
                    item._type === 'published' && item.last_published_version || item._type !== 'published');
                return items;
            }

            function queryItems(event?, data?, params?) {
                var originalQuery;

                if (desks.changeDesk) {
                    desks.changeDesk = false;
                    monitoring.singleGroup = null;
                    multi.reset();
                }

                const scopeItemsSaved = scope.items;

                // reset in order to display loading indicator
                scope.items = undefined;

                return (function() {
                    const customFilters: {[key: string]: IMonitoringFilter} = JSON.parse(
                        scope?.group?.customFilters ?? '{}',
                    );

                    if (
                        scope.customDataSource != null
                        && typeof scope.customDataSource.getItems === 'function'
                    ) {
                        return scope.customDataSource.getItems(0, PAGE_SIZE);
                    } else if (
                        scope?.group?.type === 'search'
                        && Object.values(customFilters).some(
                            (filter) => filter?.displayOptions?.ignoreMatchesInSavedSearchMonitoringGroups,
                        )
                    ) {
                        var emptyResponse: IRestApiResponse<IArticle> = {
                            _items: [],
                            _meta: {
                                max_results: 0,
                                page: 1,
                                total: 0,
                            },
                            _links: {
                                self: {href: '', title: ''},
                                parent: {href: '', title: ''},
                            },
                        };

                        return Promise.resolve(emptyResponse);
                    } else {
                        criteria = cards.criteria(scope.group, null, monitoring.queryParam);
                        criteria.source.from = 0;
                        criteria.source.size = PAGE_SIZE;

                        // when forced refresh or query then keep query size default as set PAGE_SIZE (25) above.
                        // To compare current scope of items, consider fetching same number of items.
                        if (!(data && data.force) && scopeItemsSaved && scopeItemsSaved._items.length > PAGE_SIZE) {
                            criteria.source.size = scopeItemsSaved._items.length;
                        }

                        if (data && (data.item || data.items || data.item_id) && scope.showRefresh && !data.force) {
                            // if we know the ids of the items then try to fetch those only
                            originalQuery = angular.extend({}, criteria.source.query);

                            let items = data.items || {};

                            if (data.item || data.item_id) {
                                items[data.item || data.item_id] = 1;
                            }

                            criteria.source.query = search.getItemQuery(items);
                        }

                        if (params) {
                            angular.extend(criteria, params);
                        }

                        // custom sort for group (if it exists)
                        if (scope.customSortOptionActive) {
                            criteria.source.sort =
                                [{[scope.customSortOptionActive.field]: scope.customSortOptionActive.order}];
                        }

                        return apiquery(criteria, true)
                            .then((res) => {
                                if (originalQuery) {
                                    criteria.source.query = originalQuery;
                                }

                                return res;
                            });
                    }
                })()
                    .then((items) => {
                        if (appConfig.features.autorefreshContent && data != null) {
                            data.force = true;
                        }

                        if (!scope.showRefresh && data && !data.force && data.user !== session.identity._id) {
                            monitoring.showRefresh = scope.showRefresh = showRefresh(
                                (scopeItemsSaved?._items ?? []),
                                items._items,
                            );
                        }

                        if (!scope.showRefresh || data && data.force) {
                            scope.total = items._meta.total;
                            let onlyHighlighted = scope.group.type === 'highlights'
                                ? getOnlyHighlightsItems(items)
                                : items;

                            monitoring.totalItems = onlyHighlighted._meta.total;
                            scope.items = search.mergeItems(onlyHighlighted, scopeItemsSaved, null, true);
                        } else {
                            // update scope items only with the matching fetched items
                            scope.items = search.updateItems(items, scopeItemsSaved);
                        }
                    }).finally(() => {
                        // reset page size to default
                        criteria.source.size = PAGE_SIZE;
                    });
            }

            scope.selectDefaultSortOption = function() {
                scope.selectCustomSortOption(null);
            };

            scope.selectCustomSortOption = function(field: string | null) {
                if (field === null) {
                    scope.customSortOptionActive = null;
                } else {
                    scope.customSortOptionActive = {
                        field,
                        order: 'desc',
                    };
                }
                queryItems();
            };

            scope.toggleCustomSortOrder = function() {
                if (scope.customSortOptionActive.order === 'asc') {
                    scope.customSortOptionActive.order = 'desc';
                } else {
                    scope.customSortOptionActive.order = 'asc';
                }
                queryItems();
            };

            scope.fetchNext = function(from) {
                if (typeof criteria === 'object' && typeof criteria.source === 'object') {
                    criteria.source.from = from;
                }

                const next = true;

                (function() {
                    if (scope.customDataSource != null && typeof scope.customDataSource.getItems === 'function') {
                        return scope.customDataSource.getItems(from, PAGE_SIZE);
                    } else {
                        return apiquery(criteria, true);
                    }
                })()
                    .then((items) => {
                        scope.$applyAsync(() => {
                            if (!scope.showRefresh && scope.total !== items._meta.total) {
                                scope.total = items._meta.total;
                            }
                            let onlyHighlighted = scope.group.type === 'highlights'
                                ? getOnlyHighlightsItems(items)
                                : items;

                            scope.items = search.mergeItems(onlyHighlighted, scope.items, next);
                        });
                    });
            };

            /**
             * Request the data on search or archive endpoints
             * return {promise} list of items
             */
            function apiquery(searchCriteria, applyProjections) {
                const personalGroups = ['personal', 'sent'];
                var provider = 'search';
                const personalSectionIds = getExtensionSections().map(({id}) => id);

                if (scope.group.type === 'search' || desks.isPublishType(scope.group.type)) {
                    if (searchCriteria.repo && searchCriteria.repo.indexOf(',') === -1) {
                        provider = searchCriteria.repo;
                        if (!angular.isDefined(searchCriteria.source.size)) {
                            searchCriteria.source.size = PAGE_SIZE;
                        }
                    }
                } else if (scope.group != null
                    && (personalGroups.includes(scope.group.type)
                        || personalSectionIds.includes(scope.group.type))) {
                    provider = 'news';
                } else {
                    provider = 'archive';
                }

                if (applyProjections) {
                    searchCriteria.projections = JSON.stringify(projections);
                }

                return api.query(provider, searchCriteria);
            }

            function viewSingleGroup(group, type) {
                monitoring.viewSingleGroup(group, type);
            }
        },
    };
}