Skip to content

Commit

Permalink
Export data function (#596)
Browse files Browse the repository at this point in the history
* add export data function

* add unit tests

* fix review
  • Loading branch information
jrmyb authored Oct 30, 2024
1 parent 2b92b97 commit 5ef3765
Show file tree
Hide file tree
Showing 3 changed files with 233 additions and 15 deletions.
2 changes: 1 addition & 1 deletion apps/core/src/app/core/exportApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default function ({'core.domain.export': exportDomain}: IDeps): ICoreExpo
resolvers: {
Query: {
async export(parent, {library, attributes, filters, startAt}, ctx): Promise<string> {
return exportDomain.export(
return exportDomain.exportExcel(
{library, attributes, filters, ctx},
{
...(!!startAt && {startAt})
Expand Down
130 changes: 130 additions & 0 deletions apps/core/src/domain/export/exportDomain.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright LEAV Solutions 2017
// This file is released under LGPL V3
// License text available at https://www.gnu.org/licenses/lgpl-3.0.txt
import {IAttributeDomain} from 'domain/attribute/attributeDomain';
import {IRecordDomain} from 'domain/record/recordDomain';
import {ToAny} from 'utils/utils';
import {IQueryInfos} from '_types/queryInfos';
import exportDomain, {IExportDomainDeps} from './exportDomain';
import {AttributeFormats} from '../../_types/attribute';
import {when} from 'jest-when';

const depsBase: ToAny<IExportDomainDeps> = {
'core.domain.record': jest.fn(),
'core.domain.attribute': jest.fn(),
'core.domain.library': jest.fn(),
'core.domain.tasksManager': jest.fn(),
'core.domain.helpers.validate': jest.fn(),
'core.domain.helpers.updateTaskProgress': jest.fn(),
'core.domain.eventsManager': jest.fn(),
'core.utils': jest.fn(),
translator: {},
config: {}
};

describe('exportDomain', () => {
const mockCtx: IQueryInfos = {
userId: '1',
queryId: 'exportDomainTest'
};

describe('Export data', () => {
it('should export data with different attributes types', async () => {
const jsonMapping = JSON.stringify({
simple: 'bikes.bikes_label',
link: 'bikes.bikes_activity.activities_label',
preview: 'bikes.bikes_visual.files_previews.medium',
no_value: 'bikes.no_value',
shop_label: 'shops.shops_label'
});

const mockAttributeDomain: Mockify<IAttributeDomain> = {
getAttributeProperties: jest.fn()
};

const attributeProperties = {
bikes_label: {format: AttributeFormats.TEXT},
bikes_activity: {linked_library: 'activities'},
activities_label: {format: AttributeFormats.TEXT},
bikes_visual: {linked_library: 'files'},
files_previews: {format: AttributeFormats.EXTENDED},
no_value: {format: AttributeFormats.TEXT, linked_library: false},
shops_label: {format: AttributeFormats.TEXT}
};

Object.entries(attributeProperties).forEach(([id, returnValue]) =>
when(mockAttributeDomain.getAttributeProperties)
.calledWith({id, ctx: mockCtx})
.mockReturnValue({id, ...returnValue})
);

const mockRecordDomain: Mockify<IRecordDomain> = {
getRecordFieldValue: jest.fn()
};

const fieldValues = [
{
library: 'bikes',
recordId: 'bikeId',
attributeId: 'bikes_label',
returnValue: [{payload: 'bikeLabel'}]
},
{
library: 'bikes',
recordId: 'bikeId',
attributeId: 'bikes_activity',
returnValue: [{payload: {id: 'activityId'}}]
},
{
library: 'activities',
recordId: 'activityId',
attributeId: 'activities_label',
returnValue: [{payload: 'activityLabel'}]
},
{
library: 'bikes',
recordId: 'bikeId',
attributeId: 'bikes_visual',
returnValue: [{payload: {id: 'fileId'}}]
},
{
library: 'files',
recordId: 'fileId',
attributeId: 'files_previews',
returnValue: [{payload: JSON.stringify({medium: '/path/to/preview'})}]
},
{library: 'bikes', recordId: 'bikeId', attributeId: 'no_value', returnValue: []},
{
library: 'shops',
recordId: 'shopId',
attributeId: 'shops_label',
returnValue: [{payload: 'shopLabel'}]
}
];

fieldValues.forEach(({library, recordId, attributeId, returnValue}) =>
when(mockRecordDomain.getRecordFieldValue)
.calledWith({library, record: {id: recordId}, attributeId, ctx: mockCtx})
.mockReturnValue(returnValue)
);

const domain = exportDomain({
...depsBase,
'core.domain.record': mockRecordDomain as IRecordDomain,
'core.domain.attribute': mockAttributeDomain as IAttributeDomain
});

const data = await domain.exportData(jsonMapping, [{bikes: 'bikeId', shops: 'shopId'}], mockCtx);

expect(data).toEqual([
{
link: 'activityLabel',
no_value: '',
preview: '/path/to/preview',
simple: 'bikeLabel',
shop_label: 'shopLabel'
}
]);
});
});
});
116 changes: 102 additions & 14 deletions apps/core/src/domain/export/exportDomain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ import {pick} from 'lodash';
import path from 'path';
import {IUtils} from 'utils/utils';
import {v4 as uuidv4} from 'uuid';
import * as Config from '_types/config';
import {AttributeTypes, IAttribute} from '../../_types/attribute';
import {Errors} from '../../_types/errors';
import * as Config from '../../_types/config';
import {AttributeFormats, AttributeTypes, IAttribute} from '../../_types/attribute';
import {ErrorTypes, Errors} from '../../_types/errors';
import {IQueryInfos} from '../../_types/queryInfos';
import {IRecord, IRecordFilterLight} from '../../_types/record';
import {ITaskFuncParams, TaskPriority, TaskType} from '../../_types/tasksManager';
import {IValue} from '../../_types/value';
import {IValidateHelper} from '../helpers/validate';
import {getValuesToDisplay} from '../../utils/helpers/getValuesToDisplay';
import LeavError from '../../errors/LeavError';

export interface IExportParams {
library: string;
Expand All @@ -31,10 +33,15 @@ export interface IExportParams {
}

export interface IExportDomain {
export(params: IExportParams, task?: ITaskFuncParams): Promise<string>;
exportExcel(params: IExportParams, task?: ITaskFuncParams): Promise<string>;
exportData(
jsonMapping: string,
elements: Array<{[libraryId: string]: string}>,
ctx: IQueryInfos
): Promise<Array<{[mappingKey: string]: string}>>;
}

interface IDeps {
export interface IExportDomainDeps {
'core.domain.record': IRecordDomain;
'core.domain.attribute': IAttributeDomain;
'core.domain.library': ILibraryDomain;
Expand All @@ -58,7 +65,7 @@ export default function ({
'core.domain.eventsManager': eventsManagerDomain,
'core.utils': utils,
translator
}: IDeps): IExportDomain {
}: IExportDomainDeps): IExportDomain {
const _getFormattedValues = async (
attribute: IAttribute,
values: IValue[],
Expand Down Expand Up @@ -125,19 +132,19 @@ export default function ({
return elements;
}

const attrProps = await attributeDomain.getAttributeProperties({id: attributes[0], ctx});
const attributeProps = await attributeDomain.getAttributeProperties({id: attributes[0], ctx});

const values = [];
for (const elem of elements) {
if (Array.isArray(elem)) {
for (const e of elem) {
const value = await _extractRecordFieldValue(e, attrProps, attributes.length > 1, ctx);
const value = await _extractRecordFieldValue(e, attributeProps, attributes.length > 1, ctx);
if (value !== null) {
values.push(value);
}
}
} else {
const value = await _extractRecordFieldValue(elem, attrProps, attributes.length > 1, ctx);
const value = await _extractRecordFieldValue(elem, attributeProps, attributes.length > 1, ctx);
if (value !== null) {
values.push(value);
}
Expand All @@ -147,8 +154,81 @@ export default function ({
return _getRecFieldValue(values, attributes.slice(1), ctx);
};

const _getMappingKeysByLibrary = (mapping: Record<string, string>): Record<string, string[]> =>
Object.entries(mapping).reduce((acc, [key, value]) => {
const libraryId = value.split('.')[0];
(acc[libraryId] ??= []).push(key);
return acc;
}, {});

const _getInDepthValue = async (
libraryId: string,
recordId: string,
nestedAttribute: string[],
ctx: IQueryInfos
): Promise<string> => {
const attributeProps = await attributeDomain.getAttributeProperties({id: nestedAttribute[0], ctx});

const recordFieldValues = await recordDomain.getRecordFieldValue({
library: libraryId,
record: {id: recordId},
attributeId: nestedAttribute[0],
ctx
});

let value = getValuesToDisplay(recordFieldValues)[0]?.payload;

if (typeof value === 'undefined' || value === null) {
return '';
}

if (utils.isLinkAttribute(attributeProps)) {
return _getInDepthValue(attributeProps.linked_library, value.id, nestedAttribute.slice(1), ctx);
} else if (nestedAttribute.length > 1) {
if (attributeProps.format === AttributeFormats.EXTENDED) {
value = nestedAttribute.slice(1).reduce((acc, attr) => acc[attr], JSON.parse(value));
} else {
throw new LeavError(
ErrorTypes.VALIDATION_ERROR,
`Attribute "${attributeProps.id}" is not an extended or a link attribute, cannot access sub-attributes`
);
}
} else if (attributeProps.format === AttributeFormats.DATE_RANGE) {
value = `${value.from} - ${value.to}`;
}

return String(value);
};

return {
async export(params: IExportParams, task?: ITaskFuncParams): Promise<string> {
async exportData(
jsonMapping: string,
recordsToExport: Array<{[libraryId: string]: string}>,
ctx: IQueryInfos
): Promise<Array<{[mappingKey: string]: string}>> {
const mapping = JSON.parse(jsonMapping) as Record<string, string>;
const mappingKeysByLibrary = _getMappingKeysByLibrary(mapping);

const getMappingRecordValues = async (keys: string[], libraryId: string, recordId: string) =>
keys.reduce(async (acc, key) => {
const nestedAttributes = mapping[key].split('.').slice(1); // first element is the library id, we delete it
const value = await _getInDepthValue(libraryId, recordId, nestedAttributes, ctx);
return {...(await acc), [key]: value};
}, Promise.resolve({}));

return Promise.all(
recordsToExport.map(e =>
Object.entries(e).reduce(
async (acc, [libraryId, recordId]) => ({
...(await acc),
...(await getMappingRecordValues(mappingKeysByLibrary[libraryId], libraryId, recordId))
}),
Promise.resolve({})
)
)
);
},
async exportExcel(params: IExportParams, task?: ITaskFuncParams): Promise<string> {
const {library, attributes, filters, ctx} = params;

if (typeof task?.id === 'undefined') {
Expand Down Expand Up @@ -251,8 +331,9 @@ export default function ({
const labels = {};
for (const a of attributes) {
columns.push({header: a, key: a});
const attrProps = await attributeDomain.getAttributeProperties({id: a.split('.').pop(), ctx});
labels[a] = attrProps?.label[ctx?.lang] || attrProps?.label[config.lang.default] || attrProps.id;
const attributeProps = await attributeDomain.getAttributeProperties({id: a.split('.').pop(), ctx});
labels[a] =
attributeProps?.label[ctx?.lang] || attributeProps?.label[config.lang.default] || attributeProps.id;
}

data.columns = columns as ExcelJS.Column[];
Expand All @@ -267,8 +348,15 @@ export default function ({
const fieldValues = await _getRecFieldValue([record], attr, ctx);

// get record label or id if last attribute of full path is a link or tree type
const attrProps = await attributeDomain.getAttributeProperties({id: attr[attr.length - 1], ctx});
const value = await _getFormattedValues(attrProps, fieldValues.flat(Infinity) as IValue[], ctx);
const attributeProps = await attributeDomain.getAttributeProperties({
id: attr[attr.length - 1],
ctx
});
const value = await _getFormattedValues(
attributeProps,
fieldValues.flat(Infinity) as IValue[],
ctx
);

// set value(s) and concat them if there are several
subset[attr.join('.')] = value.map(v => v.payload).join(' | ');
Expand Down

0 comments on commit 5ef3765

Please sign in to comment.