superdesk/superdesk-client-core

View on GitHub
scripts/extensions/broadcasting/src/rundowns/rundown-view-edit.tsx

Summary

Maintainability
F
4 days
Test Coverage
/* eslint-disable react/no-multi-comp */

import * as React from 'react';
import {
    IRundown,
    IRundownExportOption,
    IRundownExportResponse,
    IRundownItem,
    IRundownItemBase,
    IRundownTemplateBase,
} from '../interfaces';
import {Button, Dropdown, IconButton, Input, SubNav} from 'superdesk-ui-framework/react';
import * as Nav from 'superdesk-ui-framework/react/components/Navigation';
import * as Layout from 'superdesk-ui-framework/react/components/Layouts';

export type IRundownAction =
    null
    | {mode: 'view'; id: string; fullWidth: boolean}
    | {mode: 'edit'; id: string; fullWidth: boolean};

interface IProps {
    rundownId: string;
    rundownAction: IRundownAction;
    rundownItemAction: IRundownItemActionNext;
    onRundownItemActionChange(action: IRundownItemActionNext): void;
    onRundownActionChange(action: IRundownAction): void;
    readOnly: boolean;
    onClose(rundown: IRundown): void;
}

interface IState {
    rundown: IRundown | null;
    rundownWithChanges: IRundown | null;
    exportOptions: Array<IRundownExportOption>;
}

import {superdesk} from '../superdesk';

import {ManageRundownItems} from './manage-rundown-items';
import {arrayInsertAtIndex, CreateValidators, downloadFileAttachment, WithValidation} from '@superdesk/common';
import {stringNotEmpty} from '../form-validation';
import {isEqual, noop} from 'lodash';
import {rundownItemStorageAdapter} from '../rundown-templates/rundown-template-item-storage-adapter';
import {LANGUAGE} from '../constants';
import {IRestApiResponse, ITopBarWidget} from 'superdesk-api';
import {AiringInfoBlock} from './components/airing-info-block';
import {commentsWidget} from '../rundown-items/widgets/comments';
import {
    IRundownItemActionNext,
    prepareForCreation,
    prepareForEditing,
    prepareForPreview,
} from './prepare-create-edit-rundown-item';
import {ISideBarTab} from 'superdesk-ui-framework/react/components/Navigation/SideBarTabs';
const {gettext} = superdesk.localization;
const {httpRequestJsonLocal} = superdesk;
const {
    getAuthoringComponent,
    getLockInfoHttpComponent,
    getLockInfoComponent,
    WithLiveResources,
    SpacerBlock,
    Spacer,
    MoreActionsButton,
} = superdesk.components;
const {generatePatch, isLockedInOtherSession} = superdesk.utilities;
const {addWebsocketMessageListener} = superdesk;
const {tryUnlocking, tryLocking} = superdesk.helpers;

const AuthoringReact = getAuthoringComponent<IRundownItem>();
const RundownLockInfo = getLockInfoHttpComponent<IRundown>();
const RundownItemLockInfo = getLockInfoComponent<IRundownItem>();

function handleUnsavedRundownChanges(
    mode: IRundownItemActionNext,
    skipUnsavedChangesCheck: boolean,
    onSuccess: () => void,
) {
    if (skipUnsavedChangesCheck === true) {
        onSuccess();

        return;
    }

    if (mode == null) {
        onSuccess();
    } else if (mode.type === 'preview') {
        onSuccess();
    } else {
        superdesk.ui.confirm(gettext('There is an item open in editing mode. Discard changes?')).then((confirmed) => {
            if (confirmed) {
                onSuccess();
            }
        });
    }
}

const sideWidgets = [
    superdesk.authoringGeneric.sideWidgets.inlineComments,
    commentsWidget,
];

const rundownValidator: CreateValidators<Partial<IRundown>> = {
    title: stringNotEmpty,
};

export function prepareRundownTemplateForSaving<T extends IRundownTemplateBase | Partial<IRundownTemplateBase>>(
    template: T,
): T {
    const copy = {...template};

    const items = copy.items ?? [];

    if (items.length > 0) {
        copy.items = items.map((item) => {
            const itemCopy = {...item};

            if (item.duration == null) {
                item.duration = item.planned_duration;
            }

            return itemCopy;
        });
    }

    return copy;
}

export function prepareRundownItemForSaving(item: Partial<IRundownItemBase>): Partial<IRundownItemBase> {
    const copy = {...item};

    if (item.duration == null) {
        item.duration = item.planned_duration;
    }

    return copy;
}

export class RundownViewEditComponent extends React.PureComponent<IProps, IState> {
    private eventListenersToRemoveBeforeUnmounting: Array<() => void>;

    constructor(props: IProps) {
        super(props);

        this.state = {
            rundown: null,
            rundownWithChanges: null,
            exportOptions: [],
        };

        this.setRundownField = this.setRundownField.bind(this);
        this.initiateCreation = this.initiateCreation.bind(this);
        this.initiateEditing = this.initiateEditing.bind(this);
        this.initiatePreview = this.initiatePreview.bind(this);
        this.save = this.save.bind(this);
        this.close = this.close.bind(this);
        this.initializeData = this.initializeData.bind(this);

        this.eventListenersToRemoveBeforeUnmounting = [];
    }

    setRundownField(data: Partial<IRundown>, callback?: () => void) {
        const {rundownWithChanges} = this.state;

        if (rundownWithChanges == null) {
            throw new Error('invalid operation');
        }

        this.setState({
            rundownWithChanges: {
                ...rundownWithChanges,
                ...data,
            },
        }, callback);
    }

    save(): void {
        if (this.state.rundownWithChanges == null || this.state.rundown == null) {
            throw new Error('invalid state');
        }

        httpRequestJsonLocal<IRundown>({
            method: 'PATCH',
            path: `/rundowns/${this.props.rundownId}`,
            payload: generatePatch(this.state.rundown, this.state.rundownWithChanges, {undefinedEqNull: true}),
            headers: {
                'If-Match': this.state.rundown._etag,
            },
        }).then((rundown) => {
            this.setState({
                rundown: rundown,
                rundownWithChanges: rundown,
            });
        });
    }

    close(rundown: IRundown) {
        if (!isEqual(rundown, this.state.rundownWithChanges)) {
            superdesk.ui.confirm(gettext('Discard unsaved changes?')).then((confirmed) => {
                if (confirmed) {
                    this.props.onClose(rundown);
                }
            });
        } else {
            this.props.onClose(rundown);
        }
    }

    initiateCreation(
        rundownId: IRundown['_id'],
        initialData: Partial<IRundownItemBase>,
        insertAtIndex?: number,
        skipUnsavedChangesCheck?: boolean,
    ) {
        handleUnsavedRundownChanges(this.props.rundownItemAction, skipUnsavedChangesCheck ?? false, () => {
            this.props.onRundownItemActionChange(
                prepareForCreation(rundownId, this.props.rundownItemAction, initialData, (val) => {
                    const itemWithDuration: Partial<IRundownItemBase> = val;

                    const {rundown, rundownWithChanges} = this.state;

                    if (rundown == null || rundownWithChanges == null) {
                        throw new Error('disallowed state');
                    }

                    return httpRequestJsonLocal<IRundownItem>({
                        method: 'POST',
                        path: '/rundown_items',
                        payload: prepareRundownItemForSaving(itemWithDuration),
                    }).then((res) => {
                        const currentItems = rundown.items ?? [];

                        return httpRequestJsonLocal<IRundown>({
                            method: 'PATCH',
                            path: `/rundowns/${this.props.rundownId}`,
                            payload: {
                                items: arrayInsertAtIndex(
                                    currentItems,
                                    {_id: res._id},
                                    insertAtIndex ?? currentItems.length,
                                ),
                            },
                            headers: {
                                'If-Match': rundown._etag,
                            },
                        }).then((rundownNext) => {
                            this.setState({
                                rundown: {
                                    ...rundown,
                                    items: rundownNext.items,
                                    _etag: rundownNext._etag,
                                },
                                rundownWithChanges: {
                                    ...rundownWithChanges,
                                    items: rundownNext.items,
                                    _etag: rundownNext._etag,
                                },
                            });

                            // needed to exit creation mode so saving again wouldn't create another item
                            this.initiateEditing(res._id, true);

                            return val;
                        });
                    });
                }),
            );
        });
    }

    initiateEditing(id: IRundownItem['_id'], skipUnsavedChangesCheck?: boolean) {
        handleUnsavedRundownChanges(this.props.rundownItemAction, skipUnsavedChangesCheck ?? false, () => {
            const action = this.props.rundownItemAction;
            const rundownItemIdToUnlock = action != null && action.type === 'edit' ? action.itemId : null;

            Promise.all([
                rundownItemIdToUnlock != null
                    ? tryUnlocking<IRundownItem>('/rundown_items', rundownItemIdToUnlock)
                    : Promise.resolve(),
                tryLocking<IRundownItem>('/rundown_items', id),
            ]).then(() => {
                /**
                 * Starting editing even if item can't be locked at the moment.
                 * There will be a button in the UI to force-unlock.
                 */
                this.props.onRundownItemActionChange(
                    prepareForEditing(this.props.rundownItemAction, id),
                );
            });
        });
    }

    initiatePreview(id: IRundownItem['_id'], skipUnsavedChangesCheck?: boolean) {
        handleUnsavedRundownChanges(this.props.rundownItemAction, skipUnsavedChangesCheck ?? false, () => {
            this.props.onRundownItemActionChange(prepareForPreview(this.props.rundownItemAction, id));
        });
    }

    private initializeData() {
        Promise.all([
            httpRequestJsonLocal<IRundown>({
                method: 'GET',
                path: `/rundowns/${this.props.rundownId}`,
            }),
            httpRequestJsonLocal<IRestApiResponse<IRundownExportOption>>({
                method: 'GET',
                path: '/rundown_export',
            }),
        ]).then(([rundown, exportOptions]) => {
            this.setState({
                rundown: rundown,
                rundownWithChanges: rundown,
                exportOptions: exportOptions._items,
            });
        });
    }

    componentDidMount() {
        this.initializeData();

        this.eventListenersToRemoveBeforeUnmounting.push(
            addWebsocketMessageListener('resource:updated', ({detail}) => {
                if (
                    detail.event === 'resource:updated'
                    && detail.extra.resource === 'rundowns'
                    && detail.extra._id === this.state.rundown?._id
                ) {
                    this.initializeData();
                }
            }),
        );
    }

    componentWillUnmount(): void {
        for (const fn of this.eventListenersToRemoveBeforeUnmounting) {
            fn();
        }
    }

    render() {
        const rundown = this.state.rundownWithChanges;
        const lockedInOtherSession = rundown == null ? true : isLockedInOtherSession(rundown);
        const editingDisallowed = lockedInOtherSession || this.props.readOnly;

        if (rundown == null) {
            return null;
        }

        const {rundownItemAction} = this.props;
        const sideWidget = rundownItemAction == null ? null : rundownItemAction.sideWidget;

        const closeBtn = (
            <Button
                text={gettext('Close')}
                onClick={() => {
                    this.close(rundown);
                }}
            />
        );

        function getAvailableSideWidgets(item: IRundownItem) {
            return sideWidgets.filter(
                ({isAllowed}) => isAllowed(
                    item,
                ),
            );
        }

        return (
            <WithValidation validators={rundownValidator}>
                {(validate, validationErrors) => (
                    <Layout.LayoutContainer>
                        <Layout.HeaderPanel>
                            <SubNav>
                                <Spacer
                                    h
                                    gap="16"
                                    justifyContent="space-between"
                                    noWrap
                                    style={{paddingInlineStart: 16}}
                                >
                                    {
                                        lockedInOtherSession
                                            ? (
                                                <RundownLockInfo
                                                    entity={rundown}
                                                    endpoint={`/rundowns/${rundown._id}`}
                                                />
                                            )
                                            : (<span />) // needed for spacer
                                    }

                                    <Spacer h gap="4" noGrow justifyContent="start" noWrap>
                                        {(() => {
                                            if (lockedInOtherSession) {
                                                return closeBtn;
                                            } else if (this.props.readOnly) {
                                                return (
                                                    <React.Fragment>
                                                        <div>{closeBtn}</div>

                                                        <div>
                                                            <Button
                                                                text={gettext('Edit Rundown')}
                                                                onClick={() => {
                                                                    const {rundownAction} = this.props;

                                                                    this.props.onRundownActionChange({
                                                                        id: this.props.rundownId,
                                                                        mode: 'edit',
                                                                        fullWidth: rundownAction?.fullWidth ?? false,
                                                                    });
                                                                }}
                                                                type="primary"
                                                            />
                                                        </div>
                                                    </React.Fragment>
                                                );
                                            } else {
                                                return (
                                                    <React.Fragment>
                                                        <div>{closeBtn}</div>

                                                        <div>
                                                            <Button
                                                                text={gettext('Save Rundown')}
                                                                onClick={() => {
                                                                    const valid = validate(rundown);

                                                                    if (valid) {
                                                                        this.save();
                                                                    }
                                                                }}
                                                                disabled={
                                                                    isEqual(
                                                                        this.state.rundown,
                                                                        this.state.rundownWithChanges,
                                                                    )
                                                                }
                                                                type="primary"
                                                            />
                                                        </div>
                                                    </React.Fragment>
                                                );
                                            }
                                        })()}

                                        <Dropdown
                                            items={[
                                                {
                                                    type: 'submenu',
                                                    label: gettext('Export'),
                                                    items: this.state.exportOptions.map((exportOption) => ({
                                                        label: exportOption.name,
                                                        onSelect: () => {
                                                            httpRequestJsonLocal<IRundownExportResponse>({
                                                                method: 'POST',
                                                                path: '/rundown_export',
                                                                payload: {
                                                                    rundown: rundown._id,
                                                                    format: exportOption._id,
                                                                },
                                                            }).then((res) => {
                                                                downloadFileAttachment(res.href);
                                                            });
                                                        },
                                                    })),
                                                },
                                            ]}
                                        >
                                            <MoreActionsButton
                                                aria-label={gettext('Actions')}
                                                onClick={noop}
                                            />
                                        </Dropdown>
                                    </Spacer>
                                </Spacer>
                            </SubNav>
                        </Layout.HeaderPanel>

                        <Layout.MainPanel padding="none">
                            <Layout.AuthoringMain
                                headerPadding="medium"
                                authoringHeader={(
                                    <AiringInfoBlock
                                        value={rundown}
                                        onChange={this.setRundownField}
                                        readOnly={editingDisallowed}
                                        validationErrors={validationErrors}
                                    />
                                )}
                                headerCollapsed={true}
                            >
                                <div>
                                    <Input
                                        type="text"
                                        value={rundown.title}
                                        onChange={(val) => {
                                            this.setRundownField({title: val});
                                        }}
                                        label={gettext('Headline')}
                                        disabled={editingDisallowed}
                                        labelHidden
                                        inlineLabel
                                        size="large"
                                        boxedStyle
                                        error={validationErrors.title ?? undefined}
                                    />

                                    <SpacerBlock v gap="16" />

                                    <WithLiveResources
                                        resources={[
                                            {
                                                resource: 'rundown_items',
                                                ids: rundown.items.map(({_id: id}) => id),
                                            },
                                        ]}
                                    >
                                        {(res) => {
                                            const rundownItems: Array<IRundownItem> = res[0]._items;

                                            return (
                                                <ManageRundownItems
                                                    rundown={rundown}
                                                    readOnly={editingDisallowed}
                                                    items={rundownItems}
                                                    initiateCreation={(initialData, insertAtIndex) => {
                                                        this.initiateCreation(
                                                            this.props.rundownId,
                                                            initialData,
                                                            insertAtIndex,
                                                        );
                                                    }}
                                                    initiateEditing={({_id}) => this.initiateEditing(_id)}
                                                    initiatePreview={({_id}) => this.initiatePreview(_id)}
                                                    onChange={(val) => {
                                                        this.setRundownField({
                                                            items: val.map(({_id}) => ({_id: _id})),
                                                        });
                                                    }}
                                                    onDelete={(_item) => {
                                                        this.setRundownField({
                                                            items: rundown.items.filter(
                                                                ({_id}) => _id !== _item._id,
                                                            ),
                                                        });
                                                    }}
                                                    selectedItem={
                                                        this.props.rundownItemAction?.type !== 'create'
                                                            ? this.props.rundownItemAction?.itemId
                                                            : null
                                                    }
                                                />
                                            );
                                        }}
                                    </WithLiveResources>
                                </div>
                            </Layout.AuthoringMain>
                        </Layout.MainPanel>

                        <Layout.RightPanel open={rundownItemAction != null}>
                            <Layout.Panel side="right" background="grey" size="xx-large">
                                <Layout.PanelContent>
                                    {
                                        rundownItemAction != null && (
                                            <AuthoringReact
                                                headerCollapsed={false}
                                                key={rundownItemAction.authoringReactKey}
                                                itemId=""
                                                resourceNames={['rundown_items']}
                                                onClose={() => {
                                                    if (rundownItemAction.type !== 'create') {
                                                        tryUnlocking<IRundown>(
                                                            '/rundown_items',
                                                            rundownItemAction.itemId,
                                                        );
                                                    }

                                                    this.props.onRundownItemActionChange(null);
                                                }}
                                                fieldsAdapter={{}}
                                                authoringStorage={rundownItemAction.authoringStorage}
                                                storageAdapter={rundownItemStorageAdapter}
                                                getLanguage={() => LANGUAGE}
                                                getInlineToolbarActions={({
                                                    item,
                                                    hasUnsavedChanges,
                                                    save,
                                                    initiateClosing,
                                                    stealLock,
                                                }) => {
                                                    const actions: Array<ITopBarWidget<IRundownItem>> = [
                                                        {
                                                            availableOffline: true,
                                                            group: 'start',
                                                            priority: 0.1,
                                                            component: () => (
                                                                <IconButton
                                                                    ariaValue={gettext('Close')}
                                                                    icon="close-small"
                                                                    onClick={() => {
                                                                        initiateClosing();
                                                                    }}
                                                                />
                                                            ),
                                                        },
                                                    ];

                                                    actions.push({
                                                        availableOffline: false,
                                                        group: 'start',
                                                        priority: 0.2,
                                                        component: () => (
                                                            <RundownItemLockInfo
                                                                allowUnlocking={rundownItemAction.type === 'edit'}
                                                                entity={item}
                                                                forceUnlock={stealLock}
                                                            />
                                                        ),
                                                    });

                                                    if (rundownItemAction.type !== 'preview') {
                                                        actions.push({
                                                            availableOffline: false,
                                                            group: 'end',
                                                            priority: 0.1,
                                                            component: () => (
                                                                <Button
                                                                    text={gettext('Save item')}
                                                                    onClick={() => {
                                                                        save();
                                                                    }}
                                                                    type="primary"
                                                                    style="hollow"
                                                                    disabled={hasUnsavedChanges() !== true}
                                                                />
                                                            ),
                                                        });
                                                    }

                                                    if (rundownItemAction.type === 'preview') {
                                                        actions.push({
                                                            availableOffline: false,
                                                            group: 'end',
                                                            priority: 0.1,
                                                            component: () => (
                                                                <Button
                                                                    text={gettext('Edit item')}
                                                                    onClick={() => {
                                                                        this.initiateEditing(item._id);
                                                                    }}
                                                                    type="primary"
                                                                    style="hollow"
                                                                />
                                                            ),
                                                        });
                                                    }

                                                    return {
                                                        readOnly: rundownItemAction.type === 'preview'
                                                            || isLockedInOtherSession(item),
                                                        toolbarBgColor: 'var(--sd-colour-bg__sliding-toolbar)',
                                                        actions: actions,
                                                    };
                                                }}
                                                getAuthoringPrimaryToolbarWidgets={() => []}
                                                secondaryToolbarWidgets={[]}
                                                getSidebarWidgetsCount={({item}) => {
                                                    return getAvailableSideWidgets(item).length;
                                                }}
                                                getSidebar={({item, toggleSideWidget}) => {
                                                    const sideWidgetsAllowed = getAvailableSideWidgets(item);

                                                    if (sideWidgetsAllowed.length < 1) {
                                                        return <span />;
                                                    }

                                                    if (rundownItemAction.type === 'create') {
                                                        return null;
                                                    }

                                                    return (
                                                        <Nav.SideBarTabs
                                                            activeTab={sideWidget?.name ?? null}
                                                            onActiveTabChange={(val) => {
                                                                toggleSideWidget(val);
                                                            }}
                                                            items={sideWidgetsAllowed.map(({icon, _id}) => {
                                                                const sidebarTab: ISideBarTab = {
                                                                    id: _id,
                                                                    size: 'big',
                                                                    icon,
                                                                };

                                                                return sidebarTab;
                                                            })}
                                                        />
                                                    );
                                                }}
                                                sideWidget={sideWidget}
                                                onSideWidgetChange={(sideWidgetNext) => {
                                                    this.props.onRundownItemActionChange({
                                                        ...rundownItemAction,
                                                        sideWidget: sideWidgetNext,
                                                    });
                                                }}
                                                getSidePanel={({
                                                    item,
                                                    contentProfile,
                                                    fieldsData,
                                                    handleFieldsDataChange,
                                                    fieldsAdapter,
                                                    storageAdapter,
                                                    authoringStorage,
                                                    handleUnsavedChanges,
                                                }) => {
                                                    const sideWidgetName = sideWidget?.name ?? null;

                                                    if (
                                                        sideWidgetName == null

                                                        // Widgets are not allowed in creation mode.
                                                        // Some require item ID.
                                                        || item._id == null
                                                    ) {
                                                        return null;
                                                    }

                                                    const widget = sideWidgets.find(({_id}) => _id === sideWidgetName);

                                                    if (widget == null) {
                                                        return null;
                                                    }

                                                    const Component = widget.component;

                                                    return (
                                                        <Component
                                                            entityId={item._id}
                                                            readOnly={editingDisallowed}
                                                            contentProfile={contentProfile}
                                                            fieldsData={fieldsData}
                                                            authoringStorage={authoringStorage}
                                                            fieldsAdapter={fieldsAdapter}
                                                            storageAdapter={storageAdapter}
                                                            handleUnsavedChanges={handleUnsavedChanges}
                                                            onFieldsDataChange={handleFieldsDataChange}
                                                        />
                                                    );
                                                }}
                                                getSideWidgetNameAtIndex={(_item, index) => sideWidgets[index].label}
                                                disableWidgetPinning
                                            />
                                        )
                                    }
                                </Layout.PanelContent>
                            </Layout.Panel>
                        </Layout.RightPanel>
                    </Layout.LayoutContainer>
                )}
            </WithValidation>
        );
    }
}

// wrap it and use key so the component re-mounts if rundownId changes
export const RundownViewEdit: React.ComponentType<IProps> =
    (props) => <RundownViewEditComponent {...props} key={props.rundownId} />;