superdesk/superdesk-client-core

View on GitHub
scripts/core/helpers/CrudManager.tsx

Summary

Maintainability
C
1 day
Test Coverage
import {appConfig} from 'appConfig';
import ng from 'core/services/ng';
import {generate} from 'json-merge-patch';
import {isObject, keyBy, partition} from 'lodash';
import {
    IBaseRestApiResponse,
    ISortOption,
    ICrudManagerFilters,
    IDataApi,
    IQueryElasticParameters,
    IArticleQueryResult,
    IArticleQuery,
    IArticle,
    IResourceChange,
} from 'superdesk-api';
import {DataProvider} from './data-provider';
import {httpRequestJsonLocal, httpRequestVoidLocal, httpRequestRawLocal, uploadFileWithProgress} from './network';
import {ignoreAbortError} from '../SuperdeskReactComponent';

export function queryElastic(
    parameters: IQueryElasticParameters,
) {
    const {endpoint, page, sort, aggregations} = parameters;

    return ng.getServices(['session', 'api'])
        .then((res: any) => {
            const [session] = res;

            function toElasticFilter(filterValues) {
                return Object.keys(filterValues ?? {}).map((key) => ({terms: {[key]: filterValues[key]}}));
            }

            const source = {
                query: {
                    filtered: {
                        filter: {
                            bool: {
                                must: toElasticFilter(parameters.filterValues),
                                must_not: [
                                    {term: {state: 'spiked'}},
                                    {term: {package_type: 'takes'}},
                                    ...toElasticFilter(parameters.filterValuesNegative),
                                ],
                                should: [
                                    {
                                        bool: {
                                            must: [
                                                {term: {state: 'draft'}},
                                                {term: {original_creator: session.identity._id}},
                                            ],
                                        },
                                    },
                                    {
                                        bool: {
                                            must_not: [
                                                {term: {state: 'draft'}},
                                            ],
                                        },
                                    },
                                ],
                                minimum_should_match: 1,
                            },
                        },
                    },
                },
                sort: sort,
                size: page.size,
                from: page.from,
            };

            const query = {
                aggregations: aggregations === true ? 1 : 0,
                es_highlight: 0,
                // projections: [],
                source,
            };

            return httpRequestJsonLocal({
                method: 'GET',
                path: `/${endpoint}`,
                urlParams: query,
            });
        });
}

export const dataApiByEntity = {
    article: {
        query: (parameters: IArticleQuery): Promise<IArticleQueryResult> =>
            queryElastic({...parameters, endpoint: 'search'}) as Promise<IArticleQueryResult>,
    },
};

export function generatePatch<T extends IBaseRestApiResponse>(item1: T, item2: Partial<T>): Partial<T> {
    const patch = (generate(item1, item2) ?? {}) as Partial<T>;

    // due to the use of "projections"(partial entities) item2 is sometimes missing fields which item1 has
    // which is triggering patching algorithm to think we want to set those missing fields to null
    // the below code enforces that in order to patch to contain null,
    // item2 must explicitly send nulls instead of missing fields
    for (const key in patch) {
        if (patch[key] === null && item2[key] !== null) {
            delete patch[key];
        }
    }

    // remove IBaseRestApiResponse fields
    delete patch['_created'];
    delete patch['_updated'];
    delete patch['_id'];
    delete patch['_etag'];
    delete patch['_links'];

    return patch;
}

export function generatePatchIArticle(a: IArticle, b: IArticle) {
    const patch = generatePatch(a, b);

    delete patch['es_highlight'];

    return patch;
}

const cache = {};

function findOne<T>(endpoint: string, id: string): Promise<T> {
    const key = `${endpoint}:${id}`;

    if (cache[key] == null) {
        cache[key] = httpRequestJsonLocal({
            method: 'GET',
            path: '/' + endpoint + '/' + id,
        }).finally(() => {
            delete cache[key];
        });
    }

    return cache[key];
}

export function fetchChangedResources<T extends IBaseRestApiResponse>(
    resource: string,
    changes: Array<IResourceChange>,
    currentItems: Array<T>,
    refreshAllOnFieldsChange: Set<string> = new Set(),
    abortSignal?: AbortSignal,
    dontRefetchForNewItems?: boolean,
): Promise<Array<T> | 'requires-refetching-all'> {
    const changesToResource = changes.filter((change) => change.resource === resource);

    if (changesToResource.length < 1) {
        return Promise.resolve(currentItems);
    }

    const [changesCreated, changesUpdatedDeleted] =
        partition(changesToResource, (change) => change.changeType === 'created');

    if (changesCreated.length > 0 && dontRefetchForNewItems !== true) {
        return Promise.resolve('requires-refetching-all');
    }

    const [changesDeleted, changesUpdated] =
        partition(changesUpdatedDeleted, (change) => change.changeType === 'deleted');

    if (
        changesUpdated.some(
            ({fields}) => (fields == null ? [] : Object.keys(fields)).some(
                (field) => refreshAllOnFieldsChange.has(field),
            ),
        )
    ) {
        return Promise.resolve('requires-refetching-all');
    }

    const deletedIds = new Set(changesDeleted.map(({itemId}) => itemId));
    const updatedIds = new Set(changesUpdated.map(({itemId}) => itemId));

    const currentItemsWithoutDeleted = currentItems.filter(({_id}) => deletedIds.has(_id) === false);

    return Promise.all(
        changesUpdated.filter(({itemId}) => updatedIds.has(itemId))
            .map(
                ({itemId}) => httpRequestJsonLocal({
                    method: 'GET',
                    path: `/${resource}/${itemId}`,
                    abortSignal: abortSignal,
                })
                    .then((res: T) => res),
            ),
    ).then((itemsUpdated) => {
        const updatedKeyed = keyBy(itemsUpdated, ({_id}) => _id);

        return currentItemsWithoutDeleted.map((item) => updatedKeyed[item._id] ?? item);
    });
}

function fetchChangedResourcesObj<T extends IBaseRestApiResponse>(
    resource: string,
    changes: Array<IResourceChange>,
    currentItems: {[id: string]: T},
    abortSignal?: AbortSignal,
): Promise<{[id: string]: T}> {
    const itemsArray = Object.values(currentItems);

    return fetchChangedResources(resource, changes, Object.values(itemsArray), new Set(), abortSignal, true)
        .then((res: Array<T>) => {
            if (res === itemsArray) {
                return currentItems; // keep the same reference if there were no changes.
            } else {
                return keyBy(res, ({_id}) => _id);
            }
        });
}

export const dataApi: IDataApi = {
    findOne: findOne,
    create: (endpoint, item, urlParams) => httpRequestJsonLocal({
        'method': 'POST',
        path: '/' + endpoint,
        payload: item,
        urlParams: urlParams ?? {},
    }),
    query: (
        endpoint: string,
        page: number,
        sortOption: ISortOption,
        filterValues: ICrudManagerFilters = {},
        max_results?: number,
        formatFiltersForServer?: (filters: ICrudManagerFilters) => ICrudManagerFilters,
    ) => {
        let query = {
            page: page,
        };

        if (sortOption != null) {
            query['sort'] = (sortOption.direction === 'descending' ? '-' : '') + sortOption.field;
        }

        if (Object.keys(filterValues).length > 0) {
            query['where'] = typeof formatFiltersForServer === 'function'
                ? formatFiltersForServer(filterValues)
                : filterValues;
        }

        if (typeof max_results === 'number') {
            query['max_results'] = max_results;
        }

        const queryString = '?' + Object.keys(query).map((key) =>
            `${key}=${isObject(query[key]) ? JSON.stringify(query[key]) : encodeURIComponent(query[key])}`).join('&');

        return httpRequestJsonLocal({
            'method': 'GET',
            path: '/' + endpoint + queryString,
        });
    },
    queryRawJson: (endpoint, params?: Dictionary<string, any>) => {
        return httpRequestJsonLocal({
            method: 'GET',
            path: '/' + endpoint,
            urlParams: params,
        });
    },
    queryRaw: (endpoint, params?: Dictionary<string, any>) => {
        return httpRequestRawLocal({
            method: 'GET',
            path: '/' + endpoint,
            urlParams: params,
        });
    },
    abortableQueryRaw: (endpoint, params?: Dictionary<string, any>) => {
        const abortController = new AbortController();

        return {
            response: ignoreAbortError(httpRequestRawLocal({
                method: 'GET',
                path: '/' + endpoint,
                urlParams: params,
                abortSignal: abortController.signal,
            })),
            abort: () => abortController.abort(),
        };
    },
    patch: (endpoint, item1, item2) => {
        const patch = generatePatch(item1, item2);

        return httpRequestJsonLocal({
            'method': 'PATCH',
            path: '/' + endpoint + '/' + item1._id,
            payload: patch,
            headers: {
                'If-Match': item1._etag,
            },
        });
    },
    patchRaw: (endpoint, id, etag, patch) => {
        return httpRequestJsonLocal({
            'method': 'PATCH',
            path: '/' + endpoint + '/' + id,
            payload: patch,
            headers: {
                'If-Match': etag,
            },
        });
    },
    delete: (endpoint, item) => httpRequestVoidLocal({
        method: 'DELETE',
        path: '/' + endpoint + '/' + item._id,
        headers: {
            'If-Match': item._etag,
        },
    }),
    uploadFileWithProgress: uploadFileWithProgress,
    createProvider: (requestFactory, responseHandler, listenTo) =>
        new DataProvider(requestFactory, responseHandler, listenTo),
};