superdesk/superdesk-client-core

View on GitHub
scripts/apps/search/components/Item.tsx

Summary

Maintainability
D
3 days
Test Coverage
/* eslint-disable react/no-multi-comp */

import React from 'react';
import classNames from 'classnames';
import {get} from 'lodash';

import {ActionsMenu} from './actions-menu/ActionsMenu';
import {closeActionsMenu, isIPublishedArticle} from '../helpers';
import {getArticleLabel, gettext} from 'core/utils';
import {ItemSwimlane} from './ItemSwimlane';
import {ItemPhotoGrid} from './ItemPhotoGrid';
import {ListItemTemplate} from './ItemListTemplate';
import {ItemMgridTemplate} from './ItemMgridTemplate';
import {IArticle, IDesk, IPublishedArticle} from 'superdesk-api';
import {querySelectorParent} from 'core/helpers/dom/querySelectorParent';
import {AuthoringWorkspaceService} from 'apps/authoring/authoring/services/AuthoringWorkspaceService';
import {httpRequestJsonLocal} from 'core/helpers/network';
import {appConfig} from 'appConfig';
import ng from 'core/services/ng';
import {IScopeApply} from 'core/utils';
import {ILegacyMultiSelect, IMultiSelectNew} from './ItemList';
import {IActivityService} from 'core/activity/activity';
import {IActivity} from 'superdesk-interfaces/Activity';
import {IRelatedEntities} from 'core/getRelatedEntities';
import {dragStart} from 'utils/dragging';

export function isButtonClicked(event): boolean {
    // don't trigger the action if a button inside a list view is clicked
    // if an extension registers a button, it should be able to totally control it.
    // target can be an image or an icon inside a button, so parents need to be checked too
    return querySelectorParent(event.target, 'button', {self: true}) != null;
}

const CLICK_TIMEOUT = 300;

const actionsMenuDefaultTemplate = (toggle, stopEvent) => (
    <div
        className="item-right toolbox"
        style={{display: 'flex', justifyContent: 'space-evenly', alignItems: 'center'}}
    >
        <button
            onClick={toggle}
            onDoubleClick={stopEvent}
            className="more-activity-toggle-ref icn-btn dropdown__toggle dropdown-toggle"
            aria-label={gettext('Item actions')}
            data-test-id="context-menu-button"
        >
            <i className="icon-dots-vertical" />
        </button>
    </div>
);

interface IProps {
    swimlane: any;
    item: IArticle | IPublishedArticle;
    relatedEntities: IRelatedEntities;
    profilesById: any;
    highlightsById: any;
    markedDesksById: any;
    ingestProvider: any;
    versioncreator: any;
    multiSelect: IMultiSelectNew | ILegacyMultiSelect;
    desk: IDesk;
    flags: any;
    view: any;
    onDbClick: any;
    onEdit: any;
    onSelect(item: IArticle, event): void;
    narrow: any;
    hideActions: boolean;
    multiSelectDisabled: boolean;
    isNested: boolean;
    actioning: boolean;
    singleLine: any;
    customRender: any;
    scopeApply: IScopeApply;
}

interface IState {
    hover: boolean;
    actioning: boolean;
    isActionMenuOpen: boolean;
    showNested: boolean;
    loading: boolean;
    nested: Array<IArticle>;
}

export class Item extends React.Component<IProps, IState> {
    private clickTimeout: number;
    private _mounted: boolean;
    private mouseDown: boolean;

    constructor(props) {
        super(props);

        this.state = {
            hover: false,
            actioning: false,
            isActionMenuOpen: false,
            showNested: false,
            loading: false,
            nested: [],
        };

        this.handleClick = this.handleClick.bind(this);
        this.edit = this.edit.bind(this);
        this.handleDoubleClick = this.handleDoubleClick.bind(this);
        this.setActioningState = this.setActioningState.bind(this);
        this.setHoverState = this.setHoverState.bind(this);
        this.unsetHoverState = this.unsetHoverState.bind(this);
        this.onDragStart = this.onDragStart.bind(this);
        this.openAuthoringView = this.openAuthoringView.bind(this);
        this.toggleNested = this.toggleNested.bind(this);
    }

    componentWillMount() {
        if (appConfig?.apps?.includes('superdesk-planning')) {
            this.loadPlanningModals();
        }
    }

    componentDidMount() {
        this._mounted = true;
    }

    componentWillUnmount() {
        this._mounted = false;
        closeActionsMenu(this.props.item._id);
    }

    componentWillReceiveProps(nextProps) {
        if (nextProps.item !== this.props.item) {
            closeActionsMenu(this.props.item._id);
        }
        this.setActioningState(nextProps.actioning);
    }

    loadPlanningModals() {
        const session = ng.get('session');
        const superdesk = ng.get('superdesk');
        const activityService: IActivityService = ng.get('activityService');

        if (!['add_to_planning', 'fulfil_assignment'].includes(get(this.props, 'item.lock_action')) ||
                get(this.props, 'item.lock_user') !== session.identity._id ||
                get(this.props, 'item.lock_session') !== session.sessionId) {
            return;
        }

        let planningActivity: IActivity | null;
        const activities = superdesk.findActivities({action: 'list', type: 'archive'},
            this.props.item);

        // Planning: if page probably was refreshed / loaded during the add-to-planning operation,
        // reload the add-to-planning modal
        if (this.props.item.lock_action === 'add_to_planning') {
            planningActivity = activities.find((a) => a._id === 'planning.addto');
        } else if (this.props.item.lock_action === 'fulfil_assignment') {
            planningActivity = activities.find((a) => a._id === 'planning.fulfil');
        }

        const openActivities = activityService.activityStack || [];

        // Item list is rerendered from planning on certain actions and this will trigger
        // opening a modal that might be open already (without page refresh)
        if (openActivities.find(({activity}) => activity._id === planningActivity._id) != null) {
            // if activity is open already, don't do anything
            return;
        }

        if (planningActivity) {
            this.setActioningState(true);
            activityService.start(planningActivity, {data: {item: this.props.item}})
                .finally(() => {
                    if (this._mounted) {
                        this.setActioningState(false);
                    }
                });
        }
    }

    shouldComponentUpdate(nextProps: IProps, nextState) {
        return nextProps.swimlane !== this.props.swimlane || nextProps.item !== this.props.item ||
            nextProps.view !== this.props.view ||
            nextProps.flags.selected !== this.props.flags.selected ||
            nextProps.narrow !== this.props.narrow ||
            nextProps.actioning !== this.props.actioning ||
            nextProps.multiSelect !== this.props.multiSelect ||
            nextState !== this.state;
    }

    handleClick(event) {
        if (isButtonClicked(event)) {
            return;
        }

        if (!this.props.item.gone && !this.clickTimeout) {
            event.persist(); // make event available in timeout callback
            this.clickTimeout = window.setTimeout(() => {
                this.clickTimeout = null;
                this.props.onSelect(this.props.item, event);
            }, CLICK_TIMEOUT);
        }
    }

    /**
     * Opens the item in authoring in view mode
     * @param {string} itemId Id of the document
     */
    openAuthoringView(itemId) {
        const authoringWorkspace: AuthoringWorkspaceService = ng.get('authoringWorkspace');

        authoringWorkspace.edit({_id: itemId}, 'view');
    }

    edit(event) {
        if (!this.props.item.gone) {
            this.props.onEdit(this.props.item);
        }
    }

    handleDoubleClick(event) {
        if (isButtonClicked(event)) {
            return;
        }

        if (this.clickTimeout) {
            window.clearTimeout(this.clickTimeout);
            this.clickTimeout = null;
        }

        if (!this.props.item.gone) {
            this.props.onDbClick(this.props.item);
        }
    }

    /**
     * Set Actioning state
     * @param {Boolean} isActioning - true if activity is in-progress, and false if completed
     */
    setActioningState(isActioning) {
        this.setState({actioning: isActioning});
    }

    setHoverState() {
        if (this.state.hover !== true) {
            this.setState({hover: true});
        }
    }

    unsetHoverState() {
        this.setState({hover: false});
    }

    onDragStart(event) {
        dragStart(event, this.props.item);
    }

    toggleNested(event) {
        event.stopPropagation();

        const showNested = !this.state.showNested;

        if (showNested && isIPublishedArticle(this.props.item) && !this.state.loading && !this.state.nested.length) {
            this.setState({loading: true});
            this.fetchNested(this.props.item);
        }

        this.setState({showNested: !this.state.showNested});
    }

    fetchNested(item: IPublishedArticle) {
        httpRequestJsonLocal<{_items: Array<IArticle>}>({
            method: 'GET',
            path: '/published',
            urlParams: {
                source: {
                    query: {
                        bool: {
                            must: {term: {family_id: item.archive_item.family_id}},
                            must_not: {term: {_id: item.item_id}},
                        },
                    },
                    sort: [{'versioncreated': 'desc'}],
                },
            },
        }).then((data) => {
            this.setState({loading: false, nested: data._items});
        }).catch(() => {
            this.setState({loading: false});
        });
    }

    render() {
        const {item} = this.props;
        let classes = this.props.view === 'photogrid' ?
            'sd-grid-item sd-grid-item--with-click' :
            'media-box media-' + item.type;

        const selectedInSingleSelectMode = this.props.flags.selected;
        const selectedInMultiSelectMode = this.props.item.selected;
        const itemSelected = selectedInSingleSelectMode || selectedInMultiSelectMode;

        // Customize item class from its props
        if (this.props.customRender && typeof this.props.customRender.getItemClass === 'function') {
            classes = `${classes} ${this.props.customRender.getItemClass(item)}`;
        }

        const isLocked: boolean = (item.lock_user && item.lock_session) != null;

        const getActionsMenu = (template = actionsMenuDefaultTemplate) =>
            this.props.hideActions !== true
            && (this.state.hover || itemSelected)
            && !item.gone
                ? (
                    <ActionsMenu
                        item={item}
                        onActioning={this.setActioningState}
                        template={template}
                        scopeApply={this.props.scopeApply}
                    />
                ) : null;

        const getTemplate = () => {
            switch (this.props.view) {
            case 'swimlane2':
                return (
                    <ItemSwimlane
                        item={item}
                        itemSelected={itemSelected}
                        isLocked={isLocked}
                        getActionsMenu={getActionsMenu}
                        multiSelect={this.props.multiSelect}
                    />
                );
            case 'mgrid':
                return (
                    <ItemMgridTemplate
                        item={item}
                        itemSelected={itemSelected}
                        desk={this.props.desk}
                        swimlane={this.props.swimlane}
                        ingestProvider={this.props.ingestProvider}
                        getActionsMenu={getActionsMenu}
                        multiSelect={this.props.multiSelect}
                    />
                );
            case 'photogrid':
                return (
                    <ItemPhotoGrid
                        item={item}
                        itemSelected={itemSelected}
                        desk={this.props.desk}
                        swimlane={this.props.swimlane}
                        multiSelect={this.props.multiSelect}
                        getActionsMenu={getActionsMenu}
                    />
                );
            default:
                return (
                    <ListItemTemplate
                        item={item}
                        relatedEntities={this.props.relatedEntities}
                        itemSelected={itemSelected}
                        desk={this.props.desk}
                        openAuthoringView={this.openAuthoringView}
                        ingestProvider={this.props.ingestProvider}
                        highlightsById={this.props.highlightsById}
                        markedDesksById={this.props.markedDesksById}
                        profilesById={this.props.profilesById}
                        swimlane={this.props.swimlane}
                        versioncreator={this.props.versioncreator}
                        narrow={this.props.narrow}
                        multiSelect={this.props.multiSelect}
                        getActionsMenu={getActionsMenu}
                        selectingDisabled={this.props.multiSelectDisabled}
                        isNested={this.props.isNested}
                        showNested={this.state.showNested}
                        toggleNested={this.toggleNested}
                        singleLine={this.props.singleLine}
                        customRender={this.props.customRender}
                    />
                );
            }
        };

        const getNested = () => {
            switch (this.props.view) {
            case 'swimlane2':
            case 'mgrid':
            case 'photogrid':
                return null;
            default:
                if (!this.state.nested.length) {
                    return null;
                }

                return (
                    <div className="sd-list-item-nested__childs sd-shadow--z1">
                        {this.state.nested.map((childItem) => (
                            <Item
                                item={childItem}
                                relatedEntities={this.props.relatedEntities}
                                key={childItem._id + childItem._current_version}
                                flags={{}}
                                profilesById={this.props.profilesById}
                                isNested={true}
                                narrow={true}
                                hideActions={true}
                                onSelect={() => null}
                                multiSelectDisabled={false}
                                swimlane={this.props.swimlane}
                                highlightsById={this.props.highlightsById}
                                markedDesksById={this.props.markedDesksById}
                                ingestProvider={this.props.ingestProvider}
                                desk={this.props.desk}
                                view={this.props.view}
                                versioncreator={this.props.versioncreator}
                                onEdit={this.props.onEdit}
                                onDbClick={this.props.onDbClick}
                                multiSelect={this.props.multiSelect}
                                actioning={false}
                                singleLine={this.props.singleLine}
                                customRender={this.props.customRender}
                                scopeApply={this.props.scopeApply}
                            />
                        ))}
                    </div>
                );
            }
        };

        // avoid any actions on nested items
        const getCallback = (func) => this.props.isNested ? (event) => event.stopPropagation() : func;

        return React.createElement(
            this.props.isNested ? 'div' : 'li',
            {
                id: item._id,
                key: item._id,
                className: classNames(
                    'list-item-view',
                    {
                        'actions-visible': this.props.hideActions !== true,
                        'active': itemSelected,
                        'selected': this.props.item.selected && !this.props.flags.selected,
                        'sd-list-item-nested': this.state.nested.length,
                        'sd-list-item-nested--expanded': this.state.nested.length && this.state.showNested,
                        'sd-list-item-nested--collapsed': this.state.nested.length && this.state.showNested === false,
                    },
                ),
                onMouseOver: getCallback(this.setHoverState),
                onMouseLeave: getCallback(this.unsetHoverState),
                onDragStart: getCallback(this.onDragStart),
                onFocus: getCallback((event) => {
                    // Only open preview on focus when it is triggered via keyboard.
                    // When mouse is used, preview will open on click.
                    if (this.mouseDown !== true) {
                        // not using this.select in order to avoid the timeout
                        // that is used to enable double-click
                        if (!this.props.item.gone) {
                            this.props.onSelect(this.props.item, event);
                        }
                    }
                }),
                onMouseDown: () => {
                    this.mouseDown = true;
                },
                onMouseUp: () => {
                    this.mouseDown = false;
                },
                onClick: getCallback(this.handleClick),
                onDoubleClick: getCallback(this.handleDoubleClick),
                onKeyDown: (event) => {
                    if (event.key === ' ') { // display item actions when space is clicked
                        const el = event.target?.querySelector('.more-activity-toggle-ref');

                        if (typeof el?.click === 'function') {
                            event.preventDefault();
                            el.click();
                        }
                    }
                },
                draggable: !this.props.isNested,
                tabIndex: 0,
                'data-test-id': 'article-item',
                'data-test-value': getArticleLabel(item),
            },
            (
                <div
                    className={classNames(classes, {
                        active: itemSelected,
                        locked: isLocked,
                        selected: itemSelected,
                        archived: item.archived || item.created,
                        gone: item.gone,
                        actioning: this.state.actioning || this.props.actioning,
                    })}
                >
                    {getTemplate()}
                </div>
            ),
            getNested(),
        );
    }
}