superdesk/superdesk-client-core

View on GitHub
scripts/core/get-superdesk-api-implementation.tsx

Summary

Maintainability
D
2 days
Test Coverage
import moment from 'moment-timezone';
import {
    ISuperdesk,
    IExtensions,
    IArticle,
    IContentProfile,
    IEvents,
    IStage,
    IUser,
    IBaseRestApiResponse,
    IPatchResponseExtraFields,
} from 'superdesk-api';
import {
    gettext,
    gettextPlural,
    stripBaseRestApiFields,
    stripHtmlTags,
    stripLockingFields,
    getProjectedFieldsArticle,
} from 'core/utils';
import {ListItem, ListItemColumn, ListItemRow, ListItemActionsMenu} from './components/ListItem';
import {getFormFieldPreviewComponent} from './ui/components/generic-form/form-field';
import {
    isIFormGroupCollapsible,
    isIFormGroup,
    isIFormField,
    FormFieldType,
} from './ui/components/generic-form/interfaces/form';
import {UserHtmlSingleLine} from './helpers/UserHtmlSingleLine';
import {Row, Item, Column} from './ui/components/List';
import {dataApi, dataApiByEntity} from './helpers/CrudManager';
import {elasticsearchApi} from './helpers/elasticsearch';
import {generateFilterForServer} from './ui/components/generic-form/generate-filter-for-server';
import {
    assertNever,
    Writeable,
    filterUndefined,
    filterKeys,
    stringToNumber,
    numberToString,
    notNullOrUndefined,
    nameof,
    isNullOrUndefined,
} from './helpers/typescript-helpers';
import {getUrlPage, setUrlPage, urlParams} from './helpers/url';
import {getLocaleForDatePicker} from './helpers/ui-framework';
import {memoize, omit} from 'lodash';
import {SelectUser} from './ui/components/SelectUser';
import {logger} from './services/logger';
import {UserAvatarFromUserId} from 'apps/users/components/UserAvatarFromUserId';
import {ArticleItemConcise} from 'core/ui/components/article-item-concise';
import {DropdownTree} from './ui/components/dropdown-tree';
import {getCssNameForExtension} from './get-css-name-for-extension';
import {Badge} from './ui/components/Badge';
import {MoreActionsButton} from './ui/components/MoreActionsButton';
import {
    getWebsocketMessageEventName,
    isWebsocketEventPublic,
} from './notification/notification';
import {Grid} from './ui/components/grid';
import {Alert} from './ui/components/alert';
import {Figure} from './ui/components/figure';
import {DropZone} from './ui/components/drop-zone';
import {GroupLabel} from './ui/components/GroupLabel';
import {TopMenuDropdownButton} from './ui/components/TopMenuDropdownButton';
import {dispatchInternalEvent} from './internal-events';
import {Icon} from './ui/components/Icon2';
import {AuthoringWorkspaceService} from 'apps/authoring/authoring/services/AuthoringWorkspaceService';
import ng from 'core/services/ng';
import {Spacer, SpacerBlock, SpacerInlineFlex} from './ui/components/Spacer';
import {appConfig} from 'appConfig';
import {httpRequestJsonLocal, httpRequestVoidLocal, httpRequestRawLocal} from './helpers/network';
import {memoize as memoizeLocal} from './memoize';
import {generatePatch} from './patch';
import {getLinesCount} from 'apps/authoring/authoring/components/line-count';
import {attachmentsApi} from 'apps/authoring/attachments/attachmentsService';
import {notify} from './notify/notify';
import {sdApi} from 'api';
import {IconBig} from './ui/components/IconBig';
import {throttleAndCombineArray} from './itemList/throttleAndCombine';
import {WithLiveQuery} from './with-live-query';
import {WithLiveResources} from './with-resources';
import {querySelectorParent} from './helpers/dom/querySelectorParent';
import {showIgnoreCancelSaveDialog} from './ui/components/IgnoreCancelSaveDialog';
import {Editor3Html} from './editor3/Editor3Html';
import {arrayToTree, treeToArray} from './helpers/tree';
import {AuthoringWidgetHeading} from 'apps/dashboard/widget-heading';
import {AuthoringWidgetLayout} from 'apps/dashboard/widget-layout';
import {patchArticle} from 'api/article-patch';
import {connectCrudManagerHttp} from './helpers/crud-manager-http';
import {GenericArrayListPageComponent} from './helpers/generic-array-list-page-component';
import {getGenericHttpEntityListPageComponent} from 'core/ui/components/ListPage/generic-list-page';
import {Center} from './ui/components/Center';
import {InputLabel} from './ui/components/input-label';
import {VirtualListFromQuery} from './ui/components/virtual-lists/virtual-list-from-query';
import {SelectFromEndpoint} from './ui/components/virtual-lists/select';
import {DateTime} from './ui/components/DateTime';
import {AuthoringReact} from 'apps/authoring-react/authoring-react';
import {computeEditor3Output} from 'apps/authoring-react/field-adapters/utilities/compute-editor3-output';
import {getContentStateFromHtml} from './editor3/html/from-html';
import {getInlineCommentsWidgetGeneric} from 'apps/authoring-react/generic-widgets/inline-comments';
import {getCommentsWidgetGeneric} from 'apps/authoring-react/generic-widgets/comments';
import {
    isLockedInOtherSession,
    isLockedInCurrentSession,
    LockInfoHttp,
    LockInfo,
} from 'apps/authoring-react/subcomponents/lock-info-generic';
import {tryLocking, tryUnlocking} from './helpers/locking-helpers';
import {showPopup} from './ui/components/popupNew';
import {Card} from './ui/components/Card';
import {getTextColor} from './helpers/utils';
import {showModal} from '@superdesk/common';
import {showConfirmationPrompt} from './ui/show-confirmation-prompt';
import {toElasticQuery} from './query-formatting';
import {getCustomFieldVocabularies, getLanguageVocabulary} from './helpers/business-logic';
import {PreviewFieldType} from 'apps/authoring/preview/previewFieldByType';
import {getLabelNameResolver} from 'apps/workspace/helpers/getLabelForFieldId';
import {getSortedFields, getSortedFieldsFiltered} from 'apps/authoring/preview/utils';

function getContentType(id): Promise<IContentProfile> {
    return dataApi.findOne('content_types', id);
}

export function openArticle(id: IArticle['_id'], mode: 'view' | 'edit' | 'edit-new-window'): Promise<void> {
    const authoringWorkspace = ng.get('authoringWorkspace');

    if (mode === 'edit-new-window') {
        authoringWorkspace.popupFromId(id, 'view');
    } else {
        const {currentPathStartsWith} = sdApi.navigation;

        if (
            currentPathStartsWith(['workspace']) !== true
            && currentPathStartsWith(['search']) !== true
        ) {
            setUrlPage('/workspace/monitoring');
        }

        authoringWorkspace.edit({_id: id}, mode);
    }

    return Promise.resolve();
}

const getContentTypeMemoized = memoize(getContentType);
let getContentTypeMemoizedLastCall: number = 0; // unix time

export const getCustomEventNamePrefixed = (name: keyof IEvents) => 'internal-event--' + name;

// stores a map between custom callback & callback passed to DOM
// so the original event listener can be removed later
const customEventMap = new Map();

export const addEventListener = <T extends keyof IEvents>(eventName: T, callback: (arg: IEvents[T]) => void) => {
    const handlerWrapper = (customEvent: CustomEvent) => callback(customEvent.detail);

    customEventMap.set(callback, handlerWrapper);

    window.addEventListener(getCustomEventNamePrefixed(eventName), handlerWrapper);
};

export const removeEventListener = <T extends keyof IEvents>(eventName: T, callback: (arg: IEvents[T]) => void) => {
    const handlerWrapper = customEventMap.get(callback);

    if (handlerWrapper != null) {
        window.removeEventListener(getCustomEventNamePrefixed(eventName), handlerWrapper);
        customEventMap.delete(callback);
    }
};

export const dispatchCustomEvent = <T extends keyof IEvents>(eventName: T, payload: IEvents[T]) => {
    window.dispatchEvent(
        new CustomEvent(getCustomEventNamePrefixed(eventName), {detail: payload}),
    );
};

export function prepareExternalImageForDroppingToEditor(
    event: DragEvent,
    renditions: IArticle['renditions'],
    additionalData?: Partial<IArticle>,
) {
    const item: Partial<IArticle> = {
        _fetchable: false,
        _type: 'externalsource',
        ...(additionalData ?? {}),
        type: 'picture',
        renditions: renditions,
    };

    event.dataTransfer.setData(
        'application/superdesk.item.picture',
        JSON.stringify(item),
    );
}

export let applicationState: Writeable<ISuperdesk['state']> = {
    articleInEditMode: undefined,
};

addEventListener('articleEditStart', (article) => {
    applicationState.articleInEditMode = article._id;
});

addEventListener('articleEditEnd', () => {
    delete applicationState['articleInEditMode'];
});

export function isArticleLockedInCurrentSession(article: IArticle): boolean {
    return ng.get('lock').isLockedInCurrentSession(article);
}

export const formatDate = (date: Date | string) => (
    moment(date)
        .tz(appConfig.default_timezone)
        .format(appConfig.view.dateformat)
);

export function dateToServerString(date: Date) {
    return date.toISOString().slice(0, 19) + '+0000';
}

export function getRelativeOrAbsoluteDateTime(
    datetimeString: string,
    format: string,
    relativeDuration: number = 1,
    relativeUnit: moment.unitOfTime.Diff = 'days',
): string {
    const datetime = moment(datetimeString);

    if (datetime.isSameOrAfter(moment().subtract(relativeDuration, relativeUnit))) {
        return datetime.fromNow();
    }

    return datetime
        .tz(appConfig.default_timezone)
        .format(format);
}

export function fixPatchRequest<T extends {}>(entity: T): T {
    return stripLockingFields(stripBaseRestApiFields(entity)) as unknown as T;
}

export function fixPatchResponse<T extends IBaseRestApiResponse>(
    entity: T & IPatchResponseExtraFields,
): T {
    return omit(entity, ['_status']) as unknown as T;
}

// imported from planning
export function getSuperdeskApiImplementation(
    requestingExtensionId: string,
    extensions: IExtensions,
    modal,
    privileges,
    lock,
    session,
    authoringWorkspace: AuthoringWorkspaceService,
    config,
    metadata,
    preferencesService,
): ISuperdesk {
    return {
        dataApi: dataApi,
        dataApiByEntity,
        elasticsearch: elasticsearchApi,
        helpers: {
            assertNever,
            filterUndefined,
            filterKeys,
            stringToNumber,
            numberToString,
            notNullOrUndefined,
            isNullOrUndefined,
            nameof: nameof,
            stripBaseRestApiFields,
            fixPatchRequest,
            fixPatchResponse,
            computeEditor3Output,
            getContentStateFromHtml: (html) => getContentStateFromHtml(html),
            tryLocking,
            tryUnlocking,
            superdeskToElasticQuery: toElasticQuery,
        },
        httpRequestJsonLocal,
        httpRequestRawLocal,
        httpRequestVoidLocal,
        getExtensionConfig: () => extensions[requestingExtensionId]?.configuration ?? {},
        entities: {
            article: {
                isPersonal: sdApi.article.isPersonal,
                isLocked: sdApi.article.isLocked,
                isLockedInCurrentSession: sdApi.article.isLockedInCurrentSession,
                isLockedInOtherSession: sdApi.article.isLockedInOtherSession,
                patch: patchArticle,
                isArchived: sdApi.article.isArchived,
                isPublished: (article) => sdApi.article.isPublished(article),
                itemAction: (article) => sdApi.article.itemAction(article),
                getProjectedFieldsArticle: getProjectedFieldsArticle,
                getLabelNameResolver: getLabelNameResolver,
                getSortedFields: getSortedFields,
                getSortedFieldsFiltered: getSortedFieldsFiltered,
            },
            desk: {
                getStagesOrdered: (deskId: string) =>
                    dataApi.query<IStage>('stages', 1, {field: '_id', direction: 'ascending'}, {desk: deskId}, 200)
                        .then((response) => response._items),
                getActiveDeskId: sdApi.desks.getActiveDeskId,
                waitTilReady: sdApi.desks.waitTilReady,
            },
            contentProfile: {
                get: (id) => {
                    // Adding simple caching since the function will be called multiple times per second.

                    // TODO: implement synchronous API(and a cache) for accessing
                    // most user settings including content profiles.

                    const timestamp = Date.now();

                    // cache for 5 seconds
                    if (timestamp - getContentTypeMemoizedLastCall > 5000) {
                        getContentTypeMemoized.cache.clear();
                    }

                    getContentTypeMemoizedLastCall = timestamp;

                    return getContentTypeMemoized(id);
                },
            },
            vocabulary: {
                getIptcSubjects: () => metadata.initialize().then(() => metadata.values.subjectcodes),
                getVocabulary: (id: string) => sdApi.vocabularies.getAll().get(id),
                getCustomFieldVocabularies: getCustomFieldVocabularies,
                getLanguageVocabulary: getLanguageVocabulary,
            },
            attachment: attachmentsApi,
            users: {
                getUsersByIds: (ids) => (
                    dataApi.query<IUser>(
                        'users',
                        1,
                        {field: 'display_name', direction: 'ascending'},
                        {_id: {$in: ids}},
                        200,
                    )
                        .then((response) => response._items)
                ),
            },
            templates: {
                getUserTemplates: sdApi.templates.getUserTemplates,
            },
        },
        state: applicationState,
        instance: {
            config,
        },
        ui: {
            article: {
                view: (id: IArticle['_id']) => {
                    openArticle(id, 'view');
                },
                addImage: (field: string, image: IArticle) => {
                    dispatchInternalEvent('addImage', {field, image});
                },
                save: () => {
                    dispatchInternalEvent('saveArticleInEditMode', null);
                },
                prepareExternalImageForDroppingToEditor,
            },
            showModal: (Component: React.ComponentType<{closeModal(): void}>, containerClass?: string) => {
                return showModal(Component, containerClass);
            },
            alert: (message: string) => modal.alert({bodyText: message}),
            confirm: (message: string, title?: string) => showConfirmationPrompt({
                title: title ?? gettext('Confirm'),
                message,
            }),
            showIgnoreCancelSaveDialog,
            notify: notify,
            framework: {
                getLocaleForDatePicker,
            },
        },
        components: {
            UserHtmlSingleLine,
            getGenericHttpEntityListPageComponent,
            getGenericArrayListPageComponent: () => GenericArrayListPageComponent,
            connectCrudManagerHttp: connectCrudManagerHttp,
            getVirtualListFromQuery: () => VirtualListFromQuery,
            SelectFromEndpoint,
            ListItem,
            ListItemColumn,
            ListItemRow,
            ListItemActionsMenu,
            List: {
                // there's no full React implementation of ListItem component
                // https://superdesk.github.io/superdesk-ui-framework/dist/#/list-item
                // as operator is used in order to prevent exposing more props
                // so it's easier to remove old usages when we have a full implementation
                Item: Item as React.ComponentType<{onClick: any}>,
                Row: Row as React.ComponentType,
                Column: Column as React.ComponentType<{grow: boolean}>,
            },
            Grid,
            Alert,
            Figure,
            DropZone,
            Badge,
            MoreActionsButton,
            SelectUser,
            UserAvatar: UserAvatarFromUserId,
            ArticleItemConcise,
            GroupLabel,
            InputLabel,
            TopMenuDropdownButton,
            Icon,
            IconBig,
            getAuthoringComponent: () => AuthoringReact,
            getLockInfoHttpComponent: () => LockInfoHttp,
            getLockInfoComponent: () => LockInfo,
            getDropdownTree: () => DropdownTree,
            Center,
            Spacer,
            SpacerBlock,
            SpacerInlineFlex,
            getLiveQueryHOC: () => WithLiveQuery,
            WithLiveResources,
            Editor3Html,
            AuthoringWidgetHeading,
            AuthoringWidgetLayout,
            DateTime,
            Card,
            showPopup,
            authoring: {
                PreviewFieldType,
            },
        },
        forms: {
            FormFieldType,
            generateFilterForServer,
            isIFormGroupCollapsible,
            isIFormGroup,
            isIFormField,
            getFormFieldPreviewComponent,
        },
        authoringGeneric: {
            sideWidgets: {
                inlineComments: getInlineCommentsWidgetGeneric(),
                comments: (
                    getComments,
                    addComment,
                    isAllowed,
                ) => getCommentsWidgetGeneric(getComments, addComment, isAllowed),
            },
        },
        localization: {
            gettext: (message, params) => gettext(message, params),
            gettextPlural: (count, singular, plural, params) => gettextPlural(count, singular, plural, params),
            formatDate: formatDate,
            formatDateTime: (date: Date) => {
                return moment(date)
                    .tz(appConfig.default_timezone)
                    .format(appConfig.view.dateformat + ' ' + appConfig.view.timeformat);
            },
            longFormatDateTime: (date: Date | string) => {
                return moment(date)
                    .tz(appConfig.default_timezone)
                    .format(appConfig.longDateFormat || 'LLL');
            },
            getRelativeOrAbsoluteDateTime: getRelativeOrAbsoluteDateTime,
        },
        privileges: {
            getOwnPrivileges: () => privileges.loaded.then(() => privileges.privileges),
            hasPrivilege: (privilege: string) => sdApi.user.hasPrivilege(privilege),
        },
        preferences: {
            get: (key) => {
                return preferencesService.get().then((res: Dictionary<string, any>) => {
                    return res?.extensions?.[requestingExtensionId]?.[key] ?? null;
                });
            },
            set: (key, value) => {
                return preferencesService.get().then((res: Dictionary<string, any>) => {
                    const extensionsPreferences = res.extensions ?? {};

                    if (extensionsPreferences[requestingExtensionId] == null) {
                        extensionsPreferences[requestingExtensionId] = {};
                    }

                    extensionsPreferences[requestingExtensionId][key] = value;

                    return preferencesService.update({extensions: extensionsPreferences});
                });
            },
        },
        session: {
            getToken: () => session.token,
            getCurrentUser: () => session.getIdentity(),
            getSessionId: () => session.sessionId,
            getCurrentUserId: () => sdApi.user.getCurrentUserId(),
        },
        browser: {
            location: {
                getPage: getUrlPage,
                setPage: setUrlPage,
                urlParams: urlParams,
            },
        },
        utilities: {
            logger,
            CSS: {
                getClass: (originalName: string) => getCssNameForExtension(originalName, requestingExtensionId),
                getId: (originalName: string) => getCssNameForExtension(originalName, requestingExtensionId),
            },
            dateToServerString,
            memoize: memoizeLocal,
            generatePatch,
            stripHtmlTags,
            getLinesCount,
            throttleAndCombineArray,
            querySelectorParent,
            arrayToTree,
            treeToArray,
            isLockedInOtherSession,
            isLockedInCurrentSession,
            getTextColor,
        },
        addWebsocketMessageListener: (eventName, handler) => {
            const eventNameFinal = getWebsocketMessageEventName(
                eventName,
                isWebsocketEventPublic(eventName) ? undefined : requestingExtensionId,
            );

            window.addEventListener(eventNameFinal, handler);

            return () => {
                window.removeEventListener(eventNameFinal, handler);
            };
        },
        addEventListener,
        removeEventListener,
        dispatchEvent: dispatchCustomEvent,
    };
}