diff --git a/packages/client/src/acl/Configuration/MenuConfigure.tsx b/packages/client/src/acl/Configuration/MenuConfigure.tsx index afe443c5c..e5de69952 100644 --- a/packages/client/src/acl/Configuration/MenuConfigure.tsx +++ b/packages/client/src/acl/Configuration/MenuConfigure.tsx @@ -1,15 +1,34 @@ -import { Checkbox, Table } from 'antd'; -import React from 'react'; +import { Checkbox, message, Table } from 'antd'; +import React, { useState } from 'react'; import { useMenuItems } from '.'; -import { useRecord } from '../..'; -import { useAPIClient } from '../../api-client'; +import { useAPIClient, useRequest } from '../../api-client'; +import { useRecord } from '../../record-provider'; export const MenuConfigure = () => { const record = useRecord(); const api = useAPIClient(); const items = useMenuItems(); + const [uids, setUids] = useState([]); + const { loading, refresh } = useRequest( + { + resource: 'roles.menuUiSchemas', + resourceOf: record.name, + action: 'list', + params: { + paginate: false, + }, + }, + { + onSuccess(data) { + setUids(data?.data?.map((schema) => schema['x-uid']) || []); + }, + }, + ); + const resource = api.resource('roles.menuUiSchemas', record.name); + const allChecked = items.length === uids.length; return ( { dataIndex: 'accessible', title: ( <> - 允许访问 + { + if (allChecked) { + await resource.set({ + values: [], + }); + } else { + await resource.set({ + values: items.map((item) => item.uid), + }); + } + refresh(); + message.success('保存成功'); + }} + />{' '} + 允许访问 ), - render: (_, schema) => ( - { - await api.request({ - url: `roles/${record.name}/menuUiSchemas:toggle/${schema.uid}`, - }); - }} - /> - ), + render: (checked, schema) => { + return ( + { + if (checked) { + const index = uids.indexOf(schema.uid); + uids.splice(index, 1); + setUids([...uids]); + } else { + setUids((prev) => [...prev, schema.uid]); + } + await resource.toggle({ + values: { + tk: schema.uid, + }, + }); + message.success('保存成功'); + }} + /> + ); + }, }, ]} - dataSource={items} + dataSource={items.map((item) => { + const accessible = uids.includes(item.uid); + return { ...item, accessible }; + })} /> ); }; diff --git a/packages/client/src/collection-manager/sub-table.tsx b/packages/client/src/collection-manager/sub-table.tsx index 6fb28ee44..caec3cd1d 100644 --- a/packages/client/src/collection-manager/sub-table.tsx +++ b/packages/client/src/collection-manager/sub-table.tsx @@ -1,7 +1,7 @@ import { observer, useForm } from '@formily/react'; import { cloneDeep } from 'lodash'; import React, { createContext, useContext, useState } from 'react'; -import { CollectionOptions, CollectionProvider, useActionContext, useRecord, useRequest } from '../'; +import { CollectionOptions, CollectionProvider, useActionContext, useRecord, useRecordIndex, useRequest } from '../'; import { useAPIClient } from '../api-client'; import { options } from './Configuration/interfaces'; @@ -104,10 +104,10 @@ const useBulkDestroyAction = () => { const { selectedRowKeys, setSelectedRowKeys } = ctx; return { async run() { - const dataSource = ctx.dataSource || []; + const dataSource: any[] = ctx.dataSource || []; ctx.setDataSource( - dataSource.filter((item) => { - return !selectedRowKeys.includes(item[ctx.rowKey]); + dataSource.filter((_, index) => { + return !selectedRowKeys.includes(index); }), ); setSelectedRowKeys([]); @@ -116,16 +116,15 @@ const useBulkDestroyAction = () => { }; const useUpdateAction = () => { - const record = useRecord(); + const recordIndex = useRecordIndex(); const form = useForm(); const { setVisible } = useActionContext(); const ctx = useContext(DataSourceContext); return { async run() { - const dataSource = ctx?.dataSource || []; - const rowKey = ctx?.rowKey; - const values = dataSource.map((item) => { - if (record[rowKey] === item[rowKey]) { + const dataSource: any[] = ctx?.dataSource || []; + const values = dataSource.map((item, index) => { + if (index === recordIndex) { return { ...form.values }; } return item; @@ -137,15 +136,14 @@ const useUpdateAction = () => { }; const useDestroyAction = () => { - const record = useRecord(); + const recordIndex = useRecordIndex(); const ctx = useContext(DataSourceContext); return { async run() { - const rowKey = ctx.rowKey; - const dataSource = ctx.dataSource || []; + const dataSource: any[] = ctx.dataSource || []; ctx.setDataSource( - dataSource.filter((item) => { - return record[rowKey] !== item[rowKey]; + dataSource.filter((_, index) => { + return recordIndex !== index; }), ); }, @@ -216,7 +214,7 @@ export const SubFieldDataSourceProvider = observer((props) => { }); export const DataSourceProvider = observer((props: any) => { - const { rowKey = 'id', collection, association } = props; + const { rowKey, collection, association } = props; const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [dataSource, setDataSource] = useState([]); const record = useRecord(); diff --git a/packages/client/src/record-provider/index.tsx b/packages/client/src/record-provider/index.tsx index ea2d2f0ae..f2f758d5f 100644 --- a/packages/client/src/record-provider/index.tsx +++ b/packages/client/src/record-provider/index.tsx @@ -1,13 +1,22 @@ -import { useRequest } from 'ahooks'; import React, { createContext, useContext } from 'react'; export const RecordContext = createContext({}); +export const RecordIndexContext = createContext(null); export const RecordProvider: React.FC<{ record: any }> = (props) => { const { record, children } = props; return {children}; }; +export const RecordIndexProvider: React.FC<{ index: any }> = (props) => { + const { index, children } = props; + return {children}; +}; + export function useRecord() { return useContext(RecordContext) as D; } + +export function useRecordIndex() { + return useContext(RecordIndexContext); +} diff --git a/packages/client/src/schema-component/antd/table/Table.Array.tsx b/packages/client/src/schema-component/antd/table/Table.Array.tsx index e47d4c6ab..3ef06fe8c 100644 --- a/packages/client/src/schema-component/antd/table/Table.Array.tsx +++ b/packages/client/src/schema-component/antd/table/Table.Array.tsx @@ -5,7 +5,7 @@ import { Table, TableColumnProps } from 'antd'; import cls from 'classnames'; import React, { useState } from 'react'; import { DndContext } from '../..'; -import { RecordProvider, useRequest, useSchemaInitializer } from '../../../'; +import { RecordIndexProvider, RecordProvider, useRequest, useSchemaInitializer } from '../../../'; const isColumnComponent = (schema: Schema) => { return schema['x-component']?.endsWith('.Column') > -1; @@ -29,9 +29,11 @@ const useTableColumns = () => { render: (v, record) => { const index = field.value?.indexOf(record); return ( - - - + + + + + ); }, } as TableColumnProps; @@ -119,9 +121,15 @@ export const TableArray: React.FC = observer((props) => { } : undefined, }; + + const defaultRowKey = (record: any) => { + return field.value?.indexOf?.(record); + }; + return (
{ }; export const TableVoid: React.FC = observer((props) => { - const { useDataSource = useDef, useSelectedRowKeys = useDefSelectedRowKeys } = props; + const { rowKey = 'id', useDataSource = useDef, useSelectedRowKeys = useDefSelectedRowKeys } = props; const field = useField(); const fieldSchema = useFieldSchema(); const form = useMemo(() => createForm(), []); @@ -108,6 +108,7 @@ export const TableVoid: React.FC = observer((props) => { { type: 'void', 'x-component': 'DataSourceProvider', 'x-component-props': { - rowKey: 'id', collection: item?.field?.target, association: { name: item.field.name, @@ -127,7 +126,6 @@ export const SubTableFieldInitializer = (props) => { expandable: { childrenColumnName: '__nochildren__', }, - rowKey: 'id', rowSelection: { type: 'checkbox', }, @@ -142,7 +140,7 @@ export const SubTableFieldInitializer = (props) => { 'x-decorator': 'Table.Column.ActionBar', 'x-component': 'Table.Column', 'x-designer': 'Table.RowActionDesigner', - 'x-initializer': 'TableRecordActionInitializers', + 'x-initializer': 'TableFieldRecordActionInitializers', properties: { actions: { type: 'void', diff --git a/packages/client/src/schema-initializer/Initializers/TableFieldRecordActionInitializers.tsx b/packages/client/src/schema-initializer/Initializers/TableFieldRecordActionInitializers.tsx new file mode 100644 index 000000000..784c74a8c --- /dev/null +++ b/packages/client/src/schema-initializer/Initializers/TableFieldRecordActionInitializers.tsx @@ -0,0 +1,186 @@ +import { MenuOutlined } from '@ant-design/icons'; +import { css } from '@emotion/css'; +import { useFieldSchema } from '@formily/react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SchemaInitializer } from '../..'; +import { useAPIClient } from '../../api-client'; +import { createDesignable, useDesignable } from '../../schema-component'; + +export const TableFieldRecordActionInitializers = (props: any) => { + const fieldSchema = useFieldSchema(); + const api = useAPIClient(); + const { refresh } = useDesignable(); + const { t } = useTranslation(); + return ( + { + const spaceSchema = fieldSchema.reduceProperties((buf, schema) => { + if (schema['x-component'] === 'Space') { + return schema; + } + return buf; + }, null); + if (!spaceSchema) { + return; + } + const dn = createDesignable({ + api, + refresh, + current: spaceSchema, + }); + dn.loadAPIClientEvents(); + dn.insertBeforeEnd(schema); + }} + items={[ + { + type: 'itemGroup', + title: t('Enable actions'), + children: [ + { + type: 'item', + title: t('View'), + component: 'ActionInitializer', + schema: { + title: '{{ t("View") }}', + type: 'void', + 'x-action': 'view', + 'x-designer': 'Action.Designer', + 'x-component': 'Action.Link', + 'x-component-props': {}, + properties: { + drawer: { + type: 'void', + 'x-component': 'Action.Drawer', + title: '{{ t("View record") }}', + properties: { + tabs: { + type: 'void', + 'x-component': 'Tabs', + 'x-component-props': {}, + properties: { + tab1: { + type: 'void', + title: '详情', + 'x-component': 'Tabs.TabPane', + 'x-component-props': {}, + properties: { + grid: { + type: 'void', + 'x-decorator': 'Form', + 'x-component': 'Grid', + 'x-read-pretty': true, + 'x-item-initializer': 'RecordBlockInitializer', + properties: {}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + type: 'item', + title: t('Edit'), + component: 'ActionInitializer', + schema: { + title: '{{ t("Edit") }}', + type: 'void', + 'x-action': 'update', + 'x-designer': 'Action.Designer', + 'x-component': 'Action.Link', + 'x-component-props': {}, + properties: { + drawer: { + type: 'void', + 'x-decorator': 'Form', + 'x-decorator-props': { + useValues: '{{ cm.useValuesFromRecord }}', + }, + 'x-component': 'Action.Drawer', + title: '{{ t("Edit record") }}', + properties: { + grid: { + type: 'void', + 'x-component': 'Grid', + 'x-initializer': 'GridFormItemInitializers', + properties: {}, + }, + footer: { + type: 'void', + 'x-component': 'Action.Drawer.Footer', + properties: { + actions: { + type: 'void', + 'x-decorator': 'DndContext', + 'x-component': 'ActionBar', + 'x-component-props': { + layout: 'one-column', + }, + properties: { + cancel: { + title: '{{ t("Cancel") }}', + 'x-action': 'cancel', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: '{{ t("Submit") }}', + 'x-action': 'submit', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ ds.useUpdateAction }}', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + type: 'item', + title: t('Delete'), + component: 'ActionInitializer', + schema: { + title: '{{ t("Delete") }}', + 'x-action': 'destroy', + 'x-designer': 'Action.Designer', + 'x-component': 'Action.Link', + 'x-component-props': { + confirm: { + title: "{{t('Delete record')}}", + content: "{{t('Are you sure you want to delete it?')}}", + }, + useAction: '{{ ds.useDestroyAction }}', + }, + }, + }, + ], + }, + ]} + > + + + ); +}; diff --git a/packages/client/src/schema-initializer/Initializers/TableRecordActionInitializers.tsx b/packages/client/src/schema-initializer/Initializers/TableRecordActionInitializers.tsx index 0d9336edb..0062414bb 100644 --- a/packages/client/src/schema-initializer/Initializers/TableRecordActionInitializers.tsx +++ b/packages/client/src/schema-initializer/Initializers/TableRecordActionInitializers.tsx @@ -126,13 +126,29 @@ export const TableRecordActionInitializers = (props: any) => { properties: { actions: { type: 'void', - 'x-initializer': 'PopupFormActionInitializers', - 'x-decorator': 'DndContext', 'x-component': 'ActionBar', 'x-component-props': { layout: 'one-column', }, - properties: {}, + properties: { + cancel: { + title: '{{ t("Cancel") }}', + 'x-action': 'cancel', + 'x-component': 'Action', + 'x-component-props': { + useAction: '{{ cm.useCancelAction }}', + }, + }, + submit: { + title: '{{ t("Submit") }}', + 'x-action': 'submit', + 'x-component': 'Action', + 'x-component-props': { + type: 'primary', + useAction: '{{ cm.useUpdateAction }}', + }, + }, + }, }, }, }, @@ -166,4 +182,4 @@ export const TableRecordActionInitializers = (props: any) => { ); -}; \ No newline at end of file +}; diff --git a/packages/client/src/schema-initializer/Initializers/index.tsx b/packages/client/src/schema-initializer/Initializers/index.tsx index a35fcf53b..4086b95c7 100644 --- a/packages/client/src/schema-initializer/Initializers/index.tsx +++ b/packages/client/src/schema-initializer/Initializers/index.tsx @@ -8,8 +8,8 @@ import { PopupFormActionInitializers } from './PopupFormActionInitializers'; import { RecordBlockInitializers } from './RecordBlockInitializers'; import { TableActionInitializers } from './TableActionInitializers'; import { TableColumnInitializers } from './TableColumnInitializers'; +import { TableFieldRecordActionInitializers } from './TableFieldRecordActionInitializers'; import { TableRecordActionInitializers } from './TableRecordActionInitializers'; - export const items = { ...Items }; export const initializes = { @@ -33,4 +33,6 @@ export const initializes = { TableColumnInitializers, // 表格当前行记录的「操作配置」 TableRecordActionInitializers, + // 表格字段(子表格)场景的当前行记录的「操作配置」 + TableFieldRecordActionInitializers, }; diff --git a/packages/database/src/__tests__/field-options/sort-by.test.ts b/packages/database/src/__tests__/field-options/sort-by.test.ts new file mode 100644 index 000000000..d52d937a4 --- /dev/null +++ b/packages/database/src/__tests__/field-options/sort-by.test.ts @@ -0,0 +1,160 @@ +import { mockDatabase } from '@nocobase/test'; +import { Database } from '../../index'; + +describe('associated field order', () => { + let db: Database; + + afterEach(async () => { + await db.close(); + }); + + beforeEach(async () => { + db = mockDatabase(); + await db.clean({ drop: true }); + + db.collection({ + name: 'users', + fields: [ + { + type: 'string', + name: 'name', + }, + { + type: 'hasMany', + name: 'posts', + sortBy: 'title', + }, + { + type: 'hasMany', + name: 'records', + sortBy: 'count', + }, + ], + }); + + db.collection({ + name: 'records', + fields: [ + { + type: 'integer', + name: 'count', + hidden: true, + }, + { + type: 'string', + name: 'name', + }, + ], + }); + + db.collection({ + name: 'posts', + fields: [ + { + type: 'string', + name: 'title', + }, + { + type: 'belongsTo', + name: 'user', + }, + { + type: 'belongsToMany', + name: 'tags', + sortBy: 'name', + }, + ], + }); + + db.collection({ + name: 'tags', + fields: [ + { type: 'string', name: 'name' }, + { + type: 'belongsToMany', + name: 'posts', + }, + ], + }); + await db.sync(); + }); + + it('should sort hasMany association', async () => { + await db.getRepository('users').create({ + values: { + name: 'u1', + posts: [{ title: 'c' }, { title: 'b' }, { title: 'a' }], + }, + }); + + const u1 = await db.getRepository('users').findOne({ + appends: ['posts'], + }); + + const u1Json = u1.toJSON(); + + const u1Posts = u1Json['posts']; + expect(u1Posts.map((p) => p['title'])).toEqual(['a', 'b', 'c']); + }); + + it('should sort belongsToMany association', async () => { + await db.getRepository('posts').create({ + values: { + title: 'p1', + tags: [{ name: 'c' }, { name: 'b' }, { name: 'a' }], + }, + }); + + const p1 = await db.getRepository('posts').findOne({ + appends: ['tags'], + }); + + const p1JSON = p1.toJSON(); + + const p1Tags = p1JSON['tags']; + expect(p1Tags.map((p) => p['name'])).toEqual(['a', 'b', 'c']); + }); + + it('should sort nested associations', async () => { + await db.getRepository('users').create({ + values: { + name: 'u1', + posts: [{ title: 'c', tags: [{ name: 'c' }, { name: 'b' }, { name: 'a' }] }, { title: 'b' }, { title: 'a' }], + }, + }); + + const u1 = await db.getRepository('users').findOne({ + appends: ['posts.tags'], + }); + + const u1Json = u1.toJSON(); + const u1Posts = u1Json['posts']; + expect(u1Posts.map((p) => p['title'])).toEqual(['a', 'b', 'c']); + + const postCTags = u1Posts[2]['tags']; + expect(postCTags.map((p) => p['name'])).toEqual(['a', 'b', 'c']); + }); + + it('should sortBy hidden field', async () => { + await db.getRepository('users').create({ + values: { + name: 'u1', + records: [ + { count: 3, name: 'c' }, + { count: 2, name: 'b' }, + { count: 1, name: 'a' }, + ], + }, + }); + + const u1 = await db.getRepository('users').findOne({ + appends: ['records'], + }); + + const u1Json = u1.toJSON(); + + const u1Records = u1Json['records']; + expect(u1Records[0].count).toBeUndefined(); + expect(u1Records.map((p) => p['name'])).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/packages/database/src/fields/belongs-to-many-field.ts b/packages/database/src/fields/belongs-to-many-field.ts index 47a95255e..cb8de0725 100644 --- a/packages/database/src/fields/belongs-to-many-field.ts +++ b/packages/database/src/fields/belongs-to-many-field.ts @@ -1,7 +1,7 @@ import { omit } from 'lodash'; import { BelongsToManyOptions as SequelizeBelongsToManyOptions } from 'sequelize'; import { Collection } from '../collection'; -import { BaseRelationFieldOptions, RelationField } from './relation-field'; +import { BaseRelationFieldOptions, MultipleRelationFieldOptions, RelationField } from './relation-field'; export class BelongsToManyField extends RelationField { get through() { @@ -62,7 +62,7 @@ export class BelongsToManyField extends RelationField { } export interface BelongsToManyFieldOptions - extends BaseRelationFieldOptions, + extends MultipleRelationFieldOptions, Omit { type: 'belongsToMany'; through?: string; diff --git a/packages/database/src/fields/has-many-field.ts b/packages/database/src/fields/has-many-field.ts index 93ab7abe8..eddc624b4 100644 --- a/packages/database/src/fields/has-many-field.ts +++ b/packages/database/src/fields/has-many-field.ts @@ -5,9 +5,10 @@ import { ForeignKeyOptions, HasManyOptions, HasManyOptions as SequelizeHasManyOptions, - Utils + Utils, } from 'sequelize'; -import { BaseRelationFieldOptions, RelationField } from './relation-field'; + +import { BaseRelationFieldOptions, MultipleRelationFieldOptions, RelationField } from './relation-field'; export interface HasManyFieldOptions extends HasManyOptions { /** @@ -136,7 +137,7 @@ export class HasManyField extends RelationField { } } -export interface HasManyFieldOptions extends BaseRelationFieldOptions, SequelizeHasManyOptions { +export interface HasManyFieldOptions extends MultipleRelationFieldOptions, SequelizeHasManyOptions { type: 'hasMany'; target?: string; } diff --git a/packages/database/src/fields/relation-field.ts b/packages/database/src/fields/relation-field.ts index 52d72d010..80e822d35 100644 --- a/packages/database/src/fields/relation-field.ts +++ b/packages/database/src/fields/relation-field.ts @@ -2,6 +2,10 @@ import { BaseFieldOptions, Field } from './field'; export interface BaseRelationFieldOptions extends BaseFieldOptions {} +export interface MultipleRelationFieldOptions extends BaseRelationFieldOptions { + sortBy?: string | string[]; +} + export abstract class RelationField extends Field { /** * target relation name diff --git a/packages/database/src/model.ts b/packages/database/src/model.ts index 0c8f2fb5e..edb6ed86e 100644 --- a/packages/database/src/model.ts +++ b/packages/database/src/model.ts @@ -1,62 +1,112 @@ -import { Model as SequelizeModel } from 'sequelize'; +import { Model as SequelizeModel, ModelCtor } from 'sequelize'; import { Collection } from './collection'; import { Database } from './database'; +import lodash from 'lodash'; +import { Field } from './fields'; interface IModel { [key: string]: any; } +interface JSONTransformerOptions { + model: ModelCtor; + collection: Collection; + db: Database; + key?: string; + field?: Field; +} + export class Model extends SequelizeModel implements IModel { public static database: Database; public static collection: Collection; - // [key: string]: any; - - private toJsonWithoutHiddenFields(data, { model, collection }): any { - if (!data) { - return data; - } - if (typeof data.toJSON === 'function') { - data = data.toJSON(); - } - const db = (this.constructor as any).database as Database; - const hidden = []; - collection.forEachField((field) => { - if (field.options.hidden) { - hidden.push(field.options.name); - } - }); - const json = {}; - Object.keys(data).forEach((key) => { - if (hidden.includes(key)) { - return; - } - if (model.hasAlias(key)) { - const association = model.associations[key]; - const opts = { - model: association.target, - collection: db.getCollection(association.target.name), - }; - if (['HasMany', 'BelongsToMany'].includes(association.associationType)) { - if (Array.isArray(data[key])) { - json[key] = data[key].map((item) => this.toJsonWithoutHiddenFields(item, opts)); - } - } else { - json[key] = this.toJsonWithoutHiddenFields(data[key], opts); - } - } else { - json[key] = data[key]; - } - }); - return json; - } public toJSON(): T { - return this.toJsonWithoutHiddenFields(super.toJSON(), { - model: this.constructor, + const handleObj = (obj, options: JSONTransformerOptions) => { + const handles = [ + (data) => { + if (data instanceof Model) { + return data.toJSON(); + } + + return data; + }, + this.hiddenObjKey, + ]; + return handles.reduce((carry, fn) => fn.apply(this, [carry, options]), obj); + }; + + const handleArray = (arrayOfObj, options: JSONTransformerOptions) => { + const handles = [this.sortAssociations]; + return handles.reduce((carry, fn) => fn.apply(this, [carry, options]), arrayOfObj); + }; + + const opts = { + model: this.constructor as ModelCtor, collection: (this.constructor as any).collection, + db: (this.constructor as any).database as Database, + }; + + const traverseJSON = (data: T, options: JSONTransformerOptions): T => { + const { model, db, collection } = options; + // handle Object + data = handleObj(data, options); + + const result = {}; + for (const key of Object.keys(data)) { + // @ts-ignore + if (model.hasAlias(key)) { + const association = model.associations[key]; + const opts = { + model: association.target, + collection: db.getCollection(association.target.name), + db, + key, + field: collection.getField(key), + }; + + if (['HasMany', 'BelongsToMany'].includes(association.associationType)) { + result[key] = handleArray(data[key], opts).map((item) => traverseJSON(item, opts)); + } else { + result[key] = traverseJSON(data[key], opts); + } + } else { + result[key] = data[key]; + } + } + + return result as T; + }; + + return traverseJSON(super.toJSON(), opts); + } + + private hiddenObjKey(obj, options: JSONTransformerOptions) { + const hiddenFields = Array.from(options.collection.fields.values()) + .filter((field) => field.options.hidden) + .map((field) => field.options.name); + + return lodash.omit(obj, hiddenFields); + } + + private sortAssociations(data, { field }: JSONTransformerOptions): any { + const sortBy = field.options.sortBy; + return sortBy ? this.sortArray(data, sortBy) : data; + } + + private sortArray(data, sortBy: string | string[]) { + if (!lodash.isArray(sortBy)) { + sortBy = [sortBy]; + } + + const orders = sortBy.map((sortItem) => { + const direction = sortItem.startsWith('-') ? 'desc' : 'asc'; + sortItem.replace('-', ''); + return [sortItem, direction]; }); + + return lodash.sortBy(data, ...orders); } }