superdesk/superdesk-client-core

View on GitHub
scripts/extensions/broadcasting/src/rundown-templates/template-edit.tsx

Summary

Maintainability
F
3 days
Test Coverage
import * as React from 'react';
import * as Layout from 'superdesk-ui-framework/react/components/Layouts';
import {isEqual} from 'lodash';
import {
    Input,
    Select,
    Option,
    Button,
    IconButton,
    SubNav,
    ButtonGroup,
    Checkbox,
    DurationInput,
} from 'superdesk-ui-framework/react';
import {arrayInsertAtIndex, CreateValidators, WithValidation} from '@superdesk/common';
import {IRRule, IRundownItemBase, IRundownItemTemplateInitial, IRundownTemplateBase} from '../interfaces';
import {superdesk} from '../superdesk';
import {stringNotEmpty} from '../form-validation';
import {ManageRundownItems} from '../rundowns/manage-rundown-items';
import {getPartialDateFormat, toPythonDateFormat, toSuperdeskDateFormat} from '../utils/get-partial-date-format';
import {IAuthoringStorage, ITopBarWidget} from 'superdesk-api';
import {prepareForCreation, prepareForEditing, prepareForPreview} from '../rundowns/prepare-create-edit';

import {rundownTemplateItemStorageAdapter} from './rundown-template-item-storage-adapter';
import {LANGUAGE} from '../constants';
import {FrequencySimple} from '../rundowns/components/FrequencySimple';
import {handleUnsavedRundownChanges} from '../utils/handle-unsaved-rundown-changes';
import {AiringInfoBlock} from '../rundowns/components/airing-info-block';
import {prepareRundownItemForSaving} from '../rundowns/rundown-view-edit';
import {rundownItemContentProfile} from '../rundown-items/content-profile';

const {getAuthoringComponent} = superdesk.components;
const {assertNever} = superdesk.helpers;

const AuthoringReact = getAuthoringComponent<IRundownItemTemplateInitial>();

const {gettext} = superdesk.localization;

const {
    SpacerBlock,
    Spacer,
    InputLabel,
} = superdesk.components;

const dateFormatOptions = [
    getPartialDateFormat({year: true, month: true, day: true}),
    getPartialDateFormat({year: true, month: true}),
    getPartialDateFormat({month: true, day: true}),
];

export interface IWithAuthoringReactKey {
    /**
     * authoring-react doesn't remount if `authoringStorage` changes
     * key is used to instruct authoring-react when to remount
     */
    authoringReactKey: number;
}

export interface ICreate extends IWithAuthoringReactKey {
    type: 'create';
    item: IRundownItemTemplateInitial;
    authoringStorage: IAuthoringStorage<IRundownItemTemplateInitial>;
}

export interface IEdit extends IWithAuthoringReactKey {
    type: 'edit';
    item: IRundownItemTemplateInitial;
    authoringStorage: IAuthoringStorage<IRundownItemTemplateInitial>;
}

export interface IPreview extends IWithAuthoringReactKey {
    type: 'preview';
    item: IRundownItemTemplateInitial;
    authoringStorage: IAuthoringStorage<IRundownItemTemplateInitial>;
}

interface IPropsEditable {
    readOnly: false;
    templateFields: Partial<IRundownTemplateBase>;
    rundownItemAction: IRundownItemAction;
    onRundownItemActionChange(action: IRundownItemAction): void;
    toolbar?: React.ReactNode;
    onChange(template: Partial<IRundownTemplateBase>): void;
    onCancel(): void;
    onSave(): void;
    saveButtonLabel: string;
}

interface IPropsReadOnly {
    readOnly: true;
    templateFields: Partial<IRundownTemplateBase>;
    rundownItemAction: IRundownItemAction;
    onRundownItemActionChange(action: IRundownItemAction): void;
    initiateEditing(): void;
    toolbar?: React.ReactNode;
}

type IProps = IPropsEditable | IPropsReadOnly;

interface IState {
    sideWidget: null | {
        name: string;
        pinned: boolean;
    };
}

export type IRundownItemAction = ICreate | IEdit | IPreview | null;

const templateFieldsValidator: CreateValidators<Partial<IRundownTemplateBase>> = {
    title: stringNotEmpty,
    airtime_time: stringNotEmpty,
};

const contentProfile = rundownItemContentProfile;
const allFields = contentProfile.header.merge(contentProfile.content);
const readOnlyFields = allFields.filter((field) => field.fieldConfig.readOnly === true);

/**
 * Remove read-only fields to avoid getting an error from the server when saving.
 */
function dropReadOnlyFields(item: IRundownItemBase): IRundownItemBase {
    const shallowCopy = {...item};

    readOnlyFields.toArray().forEach((field) => {
        delete (shallowCopy as {[key: string]: any})[field.id];
    });

    return shallowCopy;
}

export class RundownTemplateViewEdit extends React.PureComponent<IProps, IState> {
    private templateFieldsInitial: Partial<IRundownTemplateBase>;

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

        this.handleChange = this.handleChange.bind(this);
        this.initiateCreation = this.initiateCreation.bind(this);
        this.initiateEditing = this.initiateEditing.bind(this);
        this.initiatePreview = this.initiatePreview.bind(this);
        this.getRundownItems = this.getRundownItems.bind(this);
        this.handleCancelling = this.handleCancelling.bind(this);

        this.templateFieldsInitial = {};

        this.state = {
            sideWidget: null,
        };
    }

    private getRundownItems() {
        return this.props.templateFields.items ?? [];
    }

    private handleChange(value: Partial<IRundownTemplateBase>) {
        if (this.props.readOnly !== true) {
            this.props.onChange(value);
        }
    }

    private initiateCreation(
        initialData: Partial<IRundownItemBase>,
        insertAtIndex?: number,
        skipUnsavedChangesCheck?: boolean,
    ) {
        handleUnsavedRundownChanges(this.props.rundownItemAction, skipUnsavedChangesCheck ?? false, () => {
            this.props.onRundownItemActionChange(
                prepareForCreation(this.props.rundownItemAction, initialData, (val) => {
                    if (!this.props.readOnly) {
                        const currentItems = this.getRundownItems();

                        this.props.onChange({
                            items: arrayInsertAtIndex(
                                currentItems,
                                dropReadOnlyFields(val.data as unknown as IRundownItemBase),
                                insertAtIndex ?? currentItems.length,
                            ),
                        });
                    }

                    // need to exit creation mode so saving again wouldn't create another item
                    this.initiateEditing(val.data as unknown as IRundownItemBase, true);

                    return Promise.resolve(val);
                }),
            );
        });
    }

    private initiateEditing(item: IRundownItemBase, skipUnsavedChangesCheck?: boolean) {
        /**
         * It's tricky to compare `IRundownItemBase` since it doesn't have an ID.
         * Simple referential comparison doesn't work
         * because start_time / end_time are autogenerated and are present in one item and not in another.
         */
        const rundownItemsAreEqual = (r1: IRundownItemBase, r2: IRundownItemBase) => isEqual(
            prepareRundownItemForSaving(r1),
            prepareRundownItemForSaving(r2),
        );

        handleUnsavedRundownChanges(this.props.rundownItemAction, skipUnsavedChangesCheck ?? false, () => {
            this.props.onRundownItemActionChange(
                prepareForEditing(this.props.rundownItemAction, null, item, (val) => {
                    if (!this.props.readOnly) {
                        this.props.onChange({
                            items: this.getRundownItems()
                                .map((_item) => rundownItemsAreEqual(_item, item) ? dropReadOnlyFields(val) : _item),
                        });
                    }

                    this.initiateEditing(val, true);

                    return Promise.resolve(val);
                }),
            );
        });
    }

    private initiatePreview(item: IRundownItemBase, skipUnsavedChangesCheck?: boolean) {
        handleUnsavedRundownChanges(this.props.rundownItemAction, skipUnsavedChangesCheck ?? false, () => {
            this.props.onRundownItemActionChange(prepareForPreview(this.props.rundownItemAction, null, item));
        });
    }

    private handleCancelling() {
        if (!this.props.readOnly) {
            const {onCancel} = this.props;
            const unsavedChangesPresent = !isEqual(this.templateFieldsInitial, this.props.templateFields);

            if (unsavedChangesPresent) {
                superdesk.ui.confirm(gettext('Discard unsaved changes?')).then((confirmed) => {
                    if (confirmed) {
                        onCancel();
                    }
                });
            } else {
                onCancel();
            }
        }
    }

    componentDidMount() {
        this.templateFieldsInitial = this.props.templateFields;
    }

    render() {
        const {templateFields, readOnly} = this.props;

        const headline_template = this.props.templateFields.title_template ?? {
            prefix: '',
            separator: '//',
            date_format: dateFormatOptions[0],
        };

        const rundownItems = this.getRundownItems();
        const {rundownItemAction} = this.props;

        return (
            <WithValidation validators={templateFieldsValidator}>
                {(validate, validationErrors) => (
                    <Layout.LayoutContainer>
                        <Layout.HeaderPanel>
                            <SubNav>
                                <ButtonGroup align="end" padded>
                                    {
                                        this.props.readOnly
                                            ? (
                                                <Button
                                                    text={gettext('Edit')}
                                                    onClick={this.props.initiateEditing}
                                                    type="primary"
                                                />
                                            )
                                            : (() => {
                                                const onSave = this.props.onSave;

                                                return (
                                                    <React.Fragment>
                                                        <Button
                                                            text={gettext('Cancel')}
                                                            onClick={this.handleCancelling}
                                                        />

                                                        <Button
                                                            text={this.props.saveButtonLabel}
                                                            onClick={() => {
                                                                if (validate(templateFields)) {
                                                                    onSave();
                                                                }
                                                            }}
                                                            type="primary"
                                                        />
                                                    </React.Fragment>
                                                );
                                            })()
                                    }
                                </ButtonGroup>
                            </SubNav>
                        </Layout.HeaderPanel>

                        <Layout.MainPanel padding="none">
                            <Layout.AuthoringMain
                                headerPadding="medium"
                                toolBar={this.props.toolbar}
                                authoringHeader={(
                                    <React.Fragment>
                                        <AiringInfoBlock
                                            value={templateFields}
                                            onChange={this.handleChange}
                                            readOnly={readOnly}
                                            validationErrors={validationErrors}
                                        />

                                        <Spacer v gap="16" justifyContent="start" noWrap>
                                            <Checkbox
                                                checked={templateFields.schedule != null}
                                                label={{text: gettext('Create rundowns automatically')}}
                                                onChange={(val) => {
                                                    if (val === true) {
                                                        const initialValue: IRRule = {
                                                            freq: 'WEEKLY',
                                                            interval: 1,
                                                            by_day: [0, 1, 2, 3, 4], // weekdays
                                                        };

                                                        this.handleChange({
                                                            ...templateFields,
                                                            repeat: true,
                                                            schedule: initialValue,
                                                        });
                                                    } else {
                                                        this.handleChange({
                                                            ...templateFields,
                                                            repeat: false,
                                                            schedule: null,
                                                        });
                                                    }
                                                }}
                                                disabled={this.props.readOnly}
                                            />

                                            {
                                                templateFields.schedule != null && (
                                                    <Spacer v gap="16" noGrow>
                                                        <FrequencySimple
                                                            value={templateFields.schedule}
                                                            onChange={(val) => {
                                                                this.handleChange({
                                                                    ...templateFields,
                                                                    schedule: val,
                                                                });
                                                            }}

                                                            // firstDayOfWeek starts from Monday
                                                            // - config.startingDay from Sunday
                                                            firstDayOfWeek={superdesk.instance.config.startingDay - 1}
                                                            readOnly={this.props.readOnly}
                                                        />

                                                        <DurationInput
                                                            label={gettext('Before airtime')}
                                                            seconds={templateFields.autocreate_before_seconds ?? 0}
                                                            onChange={(val) => {
                                                                this.handleChange({
                                                                    ...templateFields,
                                                                    autocreate_before_seconds: val,
                                                                });
                                                            }}
                                                            disabled={this.props.readOnly}
                                                        />
                                                    </Spacer>
                                                )
                                            }
                                        </Spacer>
                                    </React.Fragment>
                                )}
                            >
                                <div>
                                    <SpacerBlock v gap="16" />

                                    {/** spacing between fields */}
                                    <Spacer v gap="16">
                                        <Input
                                            label={gettext('Template name')}
                                            type="text"
                                            value={templateFields.title ?? ''}
                                            onChange={(val: string) => {
                                                this.handleChange({
                                                    ...templateFields,
                                                    title: val,
                                                });
                                            }}
                                            disabled={readOnly}
                                            error={validationErrors.title ?? undefined}
                                        />

                                        <div>
                                            <InputLabel
                                                text={gettext('Generated name for created rundowns')}
                                            />

                                            <SpacerBlock v gap="4" />

                                            {/** spacing for generated rundown name */}
                                            <Spacer h gap="16" justifyContent="start" noWrap>
                                                <div>
                                                    <Input
                                                        label=""
                                                        inlineLabel
                                                        labelHidden
                                                        type="text"
                                                        value={headline_template.prefix ?? ''}
                                                        onChange={(val: string) => {
                                                            this.handleChange({
                                                                ...templateFields,
                                                                title_template: {
                                                                    ...headline_template,
                                                                    prefix: val,
                                                                },
                                                            });
                                                        }}
                                                        disabled={readOnly}
                                                    />
                                                </div>

                                                <div style={{width: 50}}>
                                                    <Input
                                                        label=""
                                                        inlineLabel
                                                        labelHidden
                                                        type="text"
                                                        value={headline_template.separator}
                                                        onChange={(val) => {
                                                            this.handleChange({
                                                                ...templateFields,
                                                                title_template: {
                                                                    ...headline_template,
                                                                    separator: val,
                                                                },
                                                            });
                                                        }}
                                                        disabled={readOnly}
                                                    />
                                                </div>

                                                <div>
                                                    <Select
                                                        value={toSuperdeskDateFormat(headline_template.date_format)}
                                                        onChange={(val) => {
                                                            this.handleChange({
                                                                ...templateFields,
                                                                title_template: {
                                                                    ...headline_template,
                                                                    date_format: toPythonDateFormat(val),
                                                                },
                                                            });
                                                        }}
                                                        label=""
                                                        labelHidden
                                                        inlineLabel
                                                        disabled={readOnly}
                                                    >
                                                        {
                                                            dateFormatOptions.map((format) => (
                                                                <Option key={format}>{format}</Option>
                                                            ))
                                                        }
                                                    </Select>
                                                </div>
                                            </Spacer>
                                        </div>

                                        {
                                            templateFields.airtime_time != null && (() => {
                                                return (
                                                    <div>
                                                        <ManageRundownItems
                                                            rundown={null}
                                                            readOnly={readOnly}
                                                            items={rundownItems}
                                                            initiateCreation={(initialData, insertAtIndex) => {
                                                                this.initiateCreation(
                                                                    initialData,
                                                                    insertAtIndex,
                                                                );
                                                            }}
                                                            initiateEditing={this.initiateEditing}
                                                            initiatePreview={this.initiatePreview}
                                                            onChange={(val) => {
                                                                this.handleChange({
                                                                    items: val,
                                                                });
                                                            }}
                                                            onDelete={(item) => {
                                                                this.handleChange({
                                                                    items: rundownItems.filter(
                                                                        (_item) => _item !== item,
                                                                    ),
                                                                });
                                                            }}
                                                        />
                                                    </div>
                                                );
                                            })()
                                        }
                                    </Spacer>
                                </div>
                            </Layout.AuthoringMain>
                        </Layout.MainPanel>

                        <Layout.RightPanel open={rundownItemAction != null}>
                            <Layout.Panel side="right" background="grey">
                                <Layout.PanelContent>
                                    {
                                        rundownItemAction != null && (
                                            <AuthoringReact
                                                key={rundownItemAction.authoringReactKey + rundownItemAction.type}
                                                itemId=""
                                                resourceNames={[]} // isn't applicable to embedded items
                                                onClose={() => {
                                                    this.props.onRundownItemActionChange(null);
                                                }}
                                                fieldsAdapter={{}}
                                                authoringStorage={rundownItemAction.authoringStorage}
                                                storageAdapter={rundownTemplateItemStorageAdapter}
                                                getLanguage={() => LANGUAGE}
                                                getSidebarWidgetsCount={() => 0}
                                                sideWidget={this.state.sideWidget}
                                                onSideWidgetChange={(sideWidget) => {
                                                    this.setState({sideWidget});
                                                }}
                                                getInlineToolbarActions={({
                                                    hasUnsavedChanges,
                                                    save,
                                                    initiateClosing,
                                                }) => {
                                                    const actions: Array<ITopBarWidget<IRundownItemTemplateInitial>> = [
                                                        {
                                                            availableOffline: true,
                                                            group: 'start',
                                                            priority: 0.1,
                                                            component: () => (
                                                                <IconButton
                                                                    ariaValue={gettext('Close')}
                                                                    icon="close-small"
                                                                    onClick={() => {
                                                                        initiateClosing();
                                                                    }}
                                                                />
                                                            ),
                                                        },
                                                    ];

                                                    if (
                                                        rundownItemAction.type === 'edit'
                                                        || rundownItemAction.type === 'create') {
                                                        actions.push({
                                                            availableOffline: false,
                                                            group: 'end',
                                                            priority: 0.1,
                                                            component: () => (
                                                                <Button
                                                                    text={gettext('Apply')}
                                                                    onClick={() => {
                                                                        save();
                                                                    }}
                                                                    type="primary"
                                                                    disabled={hasUnsavedChanges() !== true}
                                                                />
                                                            ),
                                                        });
                                                    } else if (rundownItemAction.type === 'preview') {
                                                        actions.push({
                                                            availableOffline: false,
                                                            group: 'end',
                                                            priority: 0.1,
                                                            component: () => (
                                                                <Button
                                                                    text={gettext('Edit')}
                                                                    onClick={() => {
                                                                        const {data} = rundownItemAction.item;

                                                                        this.initiateEditing(data as IRundownItemBase);
                                                                    }}
                                                                    type="primary"
                                                                />
                                                            ),
                                                        });
                                                    } else {
                                                        assertNever(rundownItemAction);
                                                    }

                                                    return {
                                                        readOnly:
                                                            rundownItemAction.type !== 'edit'
                                                            && rundownItemAction.type !== 'create',
                                                        toolbarBgColor: 'var(--sd-colour-bg__sliding-toolbar)',
                                                        actions,
                                                    };
                                                }}
                                                getSideWidgetNameAtIndex={() => ''}
                                                getAuthoringPrimaryToolbarWidgets={() => []}
                                                secondaryToolbarWidgets={[]}
                                                disableWidgetPinning
                                            />
                                        )
                                    }
                                </Layout.PanelContent>
                            </Layout.Panel>
                        </Layout.RightPanel>
                    </Layout.LayoutContainer>
                )}
            </WithValidation>
        );
    }
}