superdesk/superdesk-client-core

View on GitHub
scripts/apps/authoring-react/data-layer.ts

Summary

Maintainability
C
1 day
Test Coverage
import {OrderedMap} from 'immutable';
import {
    IArticle,
    IAuthoringFieldV2,
    IFieldsV2,
    IContentProfileV2,
    ICommonFieldConfig,
    IAuthoringStorage,
    IFieldsAdapter,
    IAuthoringAutoSave,
    IAuthoringActionType,
} from 'superdesk-api';
import ng from 'core/services/ng';
import {httpRequestJsonLocal} from 'core/helpers/network';
import {dataApi} from 'core/helpers/CrudManager';
import {authoringApiCommon} from 'apps/authoring-bridge/authoring-api-common';
import {generatePatch} from 'core/patch';
import {appConfig} from 'appConfig';
import {getLabelNameResolver} from 'apps/workspace/helpers/getLabelForFieldId';
import {AutoSaveHttp} from './auto-save-http';
import {isObject, noop, omit} from 'lodash';
import {AUTOSAVE_TIMEOUT} from 'core/constants';
import {sdApi} from 'api';
import {getArticleAdapter} from './article-adapter';
import {gettext} from 'core/utils';
import {PACKAGE_ITEMS_FIELD_ID} from './fields/package-items';
import {description_text} from './field-adapters/description_text';
import moment from 'moment';

export function getArticleContentProfile<T>(
    item: IArticle,
    fieldsAdapter: IFieldsAdapter<T>,
): Promise<IContentProfileV2> {
    interface IFakeScope {
        schema: any;
        editor: any;
        fields: any;
    }

    let fakeScope: Partial<IFakeScope> = {};

    /**
     * Some fields require restructuring to work well in authoring-react.
     * When angular based authoring is removed, an update script should be written,
     * and this restructuring code dropped.
     */
    function adjustId(fieldId: string): string {
        switch (fieldId) {
        case 'sms':
            // in content profile the field ID is "sms"
            // but value is written to `IArticle['sms_message']`
            return 'sms_message';
        default:
            return fieldId;
        }
    }

    return Promise.all([
        getLabelNameResolver(),
        ng.get('content').setupAuthoring(item.profile, fakeScope, item),
    ]).then((res) => {
        const [getLabelForFieldId] = res;

        const {editor, fields, schema} = fakeScope;
        const fieldExists = (fieldId) => fakeScope.editor[fieldId] != null;

        // Avoid having unnecessary adapters for fields
        // to which we do not write data e.g. 'footer'.
        // Authoring react doesn't support companion
        // fields like 'footer' that don't have data on
        // their own but simply modify the data of other fields.
        const fieldsToOmit = ['footer'];

        const fieldsOrdered =
            Object.keys(editor)
                .filter((key) => editor[key] != null && !fieldsToOmit.includes(key)) // don't take disabled ones
                .map((key) => {
                    const result: {fieldId: string, editorItem: any} = {
                        fieldId: key,
                        editorItem: editor[key],
                    };

                    return result;
                })
                .sort((a, b) => a.editorItem.order - b.editorItem.order);

        let headerFields: IFieldsV2 = OrderedMap<string, IAuthoringFieldV2>();
        let contentFields: IFieldsV2 = OrderedMap<string, IAuthoringFieldV2>();

        for (const _field of fieldsOrdered) {
            const {editorItem} = _field;
            const fieldId = adjustId(_field.fieldId);

            const fieldEditor = editor[_field.fieldId] ?? {}; // unadjusted fieldId has to be used
            const fieldSchema = schema[_field.fieldId] ?? {}; // unadjusted fieldId has to be used

            const convertWidth = (width: string): number => {
                if (width === 'full') {
                    return 100;
                } else if (width === 'half') {
                    return 50;
                } else if (width === 'quarter') {
                    return 25;
                } else {
                    return 100;
                }
            };

            const commonConfigs: ICommonFieldConfig = {
                readOnly: fieldEditor.readonly === true,
                required: fieldEditor.required === true,
                allow_toggling: fieldEditor.allow_toggling === true,
                width: convertWidth(fieldEditor.sdWidth),
            };

            const fieldV2: IAuthoringFieldV2 = (() => {
                if (fieldsAdapter.hasOwnProperty(fieldId)) { // main, hardcoded fields
                    const f: IAuthoringFieldV2 = fieldsAdapter[fieldId]
                        .getFieldV2(fieldEditor, fieldSchema, fieldExists);

                    return {
                        ...f,
                        fieldConfig: {
                            ...commonConfigs,
                            ...f.fieldConfig, // adapter should be capable of overwriting common configs
                        },
                    };
                } else { // custom fields
                    const field = fields.find(({_id}) => _id === fieldId);

                    const f: IAuthoringFieldV2 = {
                        id: fieldId,
                        name: getLabelForFieldId(fieldId),
                        fieldType: field.custom_field_type,
                        fieldConfig: {
                            ...commonConfigs,
                            ...(field.custom_field_config ?? {}),
                        },
                    };

                    return f;
                }
            })();

            if (editorItem.section === 'header') {
                headerFields = headerFields.set(fieldV2.id, fieldV2);
            } else if (editorItem.section === 'content') {
                contentFields = contentFields.set(fieldV2.id, fieldV2);
            } else {
                throw new Error('invalid section');
            }
        }

        // TODO: write an upgrade script and remove hardcoding
        // after angular based authoring is removed from the codebase
        if (['picture', 'audio', 'video', 'graphic'].includes(item.type)) {
            const description_field = description_text.getFieldV2(
                fakeScope.editor,
                fakeScope.schema,
                fieldExists,
            );

            contentFields = contentFields.set(description_field.id, description_field);
        }

        const profile: IContentProfileV2 = {
            id: item.profile,
            name: 'test content profile',
            header: headerFields,
            content: contentFields,
        };

        return profile;
    });
}

function getPackagesContentProfile<T>(
    item: IArticle,
    fieldsAdapter: IFieldsAdapter<T>,
): Promise<IContentProfileV2> {
    const headlineField: IAuthoringFieldV2 = {
        id: 'headline',
        name: gettext('Headline'),
        fieldType: 'editor3',
        fieldConfig: {
            required: true,
        },
    };
    const articlesInPackageField: IAuthoringFieldV2 = {
        id: 'groups',
        name: gettext('Package items'),
        fieldType: PACKAGE_ITEMS_FIELD_ID,
        fieldConfig: {
            readOnly: false,
            allow_toggling: false,
            required: true,
        },
    };

    return Promise.resolve<IContentProfileV2>({
        id: 'packages-profile',
        name: gettext('Packages profile'),
        header: OrderedMap([
            [headlineField.id, headlineField],
        ]),
        content: OrderedMap([
            [articlesInPackageField.id, articlesInPackageField],
        ]),
    });
}

export function omitFields(
    item: Partial<IArticle>,
    omitId: boolean = false, // useful when patching
): Partial<IArticle> {
    /**
     * TODO: try getting rid of these when angular based monitoring is dropped.
     * When sending patches, these fields will automatically be excluded by patching algorithm
     * When receiving patches, server should be fixed if it sends invalid data.
     */

    const customFields = [
        '_latest_version',
        'revert_state',
        'expiry',
        '_current_version',
        'original_id',
        'ingest_version',
        'refs',
        'linked_in_packages',
    ];

    const baseApiFields = [
        '_created',
        '_links',
        '_updated',
        '_etag',
        '_status',
    ];

    if (omitId) {
        baseApiFields.push('_id');
    }

    return {...omit(item, [...customFields, ...baseApiFields])};
}

export const authoringStorageIArticle: IAuthoringStorage<IArticle> = {
    autosave: new AutoSaveHttp(AUTOSAVE_TIMEOUT),
    getEntity: (id) => {
        // TODO: take published items into account

        return dataApi.findOne<IArticle>('archive', id).then((_saved) => {
            const adapter = getArticleAdapter();

            const saved = adapter.toAuthoringReact(_saved);

            if (sdApi.article.isLockedInOtherSession(saved)) {
                return {saved, autosaved: null};
            } else if (sdApi.article.isLockedInCurrentSession(saved)) {
                return new Promise<IArticle>((resolve) => {
                    authoringStorageIArticle.autosave.get(id)
                        .then((_autosaved) => {
                            resolve(adapter.toAuthoringReact(_autosaved));
                        })
                        .catch(() => {
                            resolve(null);
                        });
                }).then((autosaved) => ({saved, autosaved}));
            } else {
                return {saved, autosaved: null};
            }
        });
    },
    isLockedInCurrentSession: (article) => sdApi.article.isLockedInCurrentSession(article),
    forceLock(entity) {
        return sdApi.article.unlock(entity._id)
            .then(() => sdApi.article.lock(entity._id))
            .then((article) => {
                const adapter = getArticleAdapter();

                return adapter.toAuthoringReact(article);
            });
    },
    saveEntity: (current, original) => {
        const adapter = getArticleAdapter();

        return authoringApiCommon.saveBefore(current, original).then((_current) => {
            const id = original._id;
            const etag = original._etag;

            let diff = generatePatch(original, _current);

            // Object patching is overriding fields of object type with diff.
            // If we make changes to such a field it is not saved correctly.
            // So we need to add all fields which are of object type to the diff object.
            Object.keys(diff).forEach((key) => {
                if (isObject(diff[key])) {
                    diff[key] = _current[key];
                }
            });

            // when object has changes, send entire object to avoid server dropping keys
            if (diff.fields_meta != null) {
                diff.fields_meta = _current.fields_meta;
            }

            // when object has changes, send entire object to avoid server dropping keys
            if (diff.extra != null) {
                diff.extra = _current.extra;
            }

            // when object has changes, send entire object to avoid server dropping keys
            if (diff.associations != null) {
                diff.associations = _current.associations;
            }

            diff = adapter.fromAuthoringReact(diff);

            const queryString = appConfig.features.publishFromPersonal === true
                ? '?publish_from_personal=true'
                : '';

            return httpRequestJsonLocal<IArticle>({
                method: 'PATCH',
                path: `/archive/${id}${queryString}`,
                payload: omitFields(diff),
                headers: {
                    'If-Match': etag,
                },
            }).then((next) => {
                authoringApiCommon.saveAfter(next, original);

                return adapter.toAuthoringReact(next);
            });
        });
    },
    getContentProfile: (item, fieldsAdapter) => {
        if (item.type === 'composite') {
            return getPackagesContentProfile(item, fieldsAdapter);
        } else {
            return getArticleContentProfile(item, fieldsAdapter);
        }
    },
    closeAuthoring: (current, original, cancelAutosave, doClose) => {
        const diff = generatePatch(original, current);
        const hasUnsavedChanges = Object.keys(diff).length > 0;

        const unlockArticle = (id: string) => httpRequestJsonLocal<void>({
            method: 'POST',
            payload: {},
            path: `/archive/${id}/unlock`,
        });

        return authoringApiCommon.closeAuthoring(
            original,
            hasUnsavedChanges,
            () => authoringStorageIArticle.saveEntity(current, original).then(() => undefined),
            () => unlockArticle(original._id),
            cancelAutosave,
            doClose,
        );
    },
    getUserPreferences: () => ng.get('preferencesService').get(),
};

class AutoSaveKill implements IAuthoringAutoSave<IArticle> {
    get() {
        return Promise.resolve({} as IArticle);
    }

    delete() {
        return Promise.resolve();
    }

    schedule(
        getItem: () => IArticle,
        callback: (autosaved: IArticle) => void,
    ) {
        callback(getItem());
    }

    cancel() {
        // noop
    }

    flush(): Promise<void> {
        return Promise.resolve();
    }
}

export const getAuthoringStorageIArticleKillOrTakedown = (
    action: IAuthoringActionType,
): IAuthoringStorage<IArticle> => ({
    ...authoringStorageIArticle,
    autosave: new AutoSaveKill(),
    getEntity: (id) => {
        return authoringStorageIArticle.getEntity(id).then(({saved, autosaved}) => {
            return sdApi.article.getItemPatchWithKillOrTakedownTemplate(saved, action).then((updated) => {
                return {
                    saved: {
                        ...updated,
                        ...saved, // updated is missing original_creator property so we get it from the saved article
                    },
                    autosaved: autosaved,
                };
            });
        });
    },
    saveEntity: () => new Promise(noop),
});

export const authoringStorageIArticleCorrect: IAuthoringStorage<IArticle> = {
    ...authoringStorageIArticle,
    autosave: new AutoSaveKill(),
    getEntity: (id) => {
        return authoringStorageIArticle.getEntity(id).then(({saved, autosaved}) => {
            const newItem = {...saved};

            newItem.flags.marked_for_sms = false;
            newItem.sms_message = '';

            const {override_ednote_for_corrections, override_ednote_template} = appConfig;
            const date = moment(newItem.versioncreated)
                .format(appConfig.view.dateformat + ' ' + appConfig.view.timeformat);

            if (override_ednote_for_corrections && override_ednote_template == null) {
                const lineBreak = '\r\n\r\n';
                const slugline = newItem.slugline ? '"' + newItem.slugline + '"' : '';

                newItem.ednote = gettext(
                    'In the story {{slugline}} sent at: {{date}}.{{lineBreak}}This is a corrected repeat.',
                    {slugline, date, lineBreak},
                );
            } else if (override_ednote_for_corrections) {
                newItem.ednote = override_ednote_template
                    .replace('{date}', date)
                    .replace('{slugline}', newItem.slugline ?? '');
            }

            delete newItem.fields_meta['ednote'];

            return {
                saved: newItem,
                autosaved: newItem,
            };
        });
    },
    saveEntity: () => new Promise(noop),
};