diff --git a/server/channels/app/custom_profile_attributes.go b/server/channels/app/custom_profile_attributes.go index 09c5f7e5d1..6b248a71b8 100644 --- a/server/channels/app/custom_profile_attributes.go +++ b/server/channels/app/custom_profile_attributes.go @@ -6,6 +6,7 @@ package app import ( "encoding/json" "net/http" + "sort" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/v8/channels/store" @@ -68,6 +69,10 @@ func (a *App) ListCPAFields() ([]*model.PropertyField, *model.AppError) { return nil, model.NewAppError("GetCPAFields", "app.custom_profile_attributes.search_property_fields.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } + sort.Slice(fields, func(i, j int) bool { + return model.CustomProfileAttributesPropertySortOrder(fields[i]) < model.CustomProfileAttributesPropertySortOrder(fields[j]) + }) + return fields, nil } diff --git a/server/channels/app/custom_profile_attributes_test.go b/server/channels/app/custom_profile_attributes_test.go index e3c3fb542d..f943759b56 100644 --- a/server/channels/app/custom_profile_attributes_test.go +++ b/server/channels/app/custom_profile_attributes_test.go @@ -51,7 +51,7 @@ func TestGetCPAField(t *testing.T) { GroupID: cpaGroupID, Name: "Test Field", Type: model.PropertyFieldTypeText, - Attrs: map[string]any{"visibility": "hidden"}, + Attrs: model.StringInterface{"visibility": "hidden"}, } createdField, err := th.App.CreateCPAField(field) @@ -76,13 +76,14 @@ func TestListCPAFields(t *testing.T) { require.NoError(t, cErr) t.Run("should list the CPA property fields", func(t *testing.T) { - field1 := &model.PropertyField{ + field1 := model.PropertyField{ GroupID: cpaGroupID, Name: "Field 1", Type: model.PropertyFieldTypeText, + Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsSortOrder: 1}, } - _, err := th.App.Srv().propertyService.CreatePropertyField(field1) + _, err := th.App.Srv().propertyService.CreatePropertyField(&field1) require.NoError(t, err) field2 := &model.PropertyField{ @@ -93,23 +94,20 @@ func TestListCPAFields(t *testing.T) { _, err = th.App.Srv().propertyService.CreatePropertyField(field2) require.NoError(t, err) - field3 := &model.PropertyField{ + field3 := model.PropertyField{ GroupID: cpaGroupID, Name: "Field 3", Type: model.PropertyFieldTypeText, + Attrs: model.StringInterface{model.CustomProfileAttributesPropertyAttrsSortOrder: 0}, } - _, err = th.App.Srv().propertyService.CreatePropertyField(field3) + _, err = th.App.Srv().propertyService.CreatePropertyField(&field3) require.NoError(t, err) fields, appErr := th.App.ListCPAFields() require.Nil(t, appErr) require.Len(t, fields, 2) - - fieldNames := []string{} - for _, field := range fields { - fieldNames = append(fieldNames, field.Name) - } - require.ElementsMatch(t, []string{"Field 1", "Field 3"}, fieldNames) + require.Equal(t, "Field 3", fields[0].Name) + require.Equal(t, "Field 1", fields[1].Name) }) } @@ -146,7 +144,7 @@ func TestCreateCPAField(t *testing.T) { GroupID: cpaGroupID, Name: model.NewId(), Type: model.PropertyFieldTypeText, - Attrs: map[string]any{"visibility": "hidden"}, + Attrs: model.StringInterface{"visibility": "hidden"}, } createdField, err := th.App.CreateCPAField(field) @@ -226,14 +224,14 @@ func TestPatchCPAField(t *testing.T) { GroupID: cpaGroupID, Name: model.NewId(), Type: model.PropertyFieldTypeText, - Attrs: map[string]any{"visibility": "hidden"}, + Attrs: model.StringInterface{"visibility": "hidden"}, } createdField, err := th.App.CreateCPAField(newField) require.Nil(t, err) patch := &model.PropertyFieldPatch{ Name: model.NewPointer("Patched name"), - Attrs: model.NewPointer(map[string]any{"visibility": "default"}), + Attrs: model.NewPointer(model.StringInterface{"visibility": "default"}), TargetID: model.NewPointer(model.NewId()), TargetType: model.NewPointer(model.NewId()), } diff --git a/server/public/model/custom_profile_attributes.go b/server/public/model/custom_profile_attributes.go index b155ffd05a..b71e8d18cc 100644 --- a/server/public/model/custom_profile_attributes.go +++ b/server/public/model/custom_profile_attributes.go @@ -4,3 +4,19 @@ package model const CustomProfileAttributesPropertyGroupName = "custom_profile_attributes" + +const CustomProfileAttributesPropertyAttrsSortOrder = "sort_order" + +func CustomProfileAttributesPropertySortOrder(p *PropertyField) int { + value, ok := p.Attrs[CustomProfileAttributesPropertyAttrsSortOrder] + if !ok { + return 0 + } + + order, ok := value.(float64) + if !ok { + return 0 + } + + return int(order) +} diff --git a/server/public/model/property_field.go b/server/public/model/property_field.go index 531b81ceb8..486630496d 100644 --- a/server/public/model/property_field.go +++ b/server/public/model/property_field.go @@ -99,7 +99,7 @@ func (pf *PropertyField) SanitizeInput() { type PropertyFieldPatch struct { Name *string `json:"name"` Type *PropertyFieldType `json:"type"` - Attrs *map[string]any `json:"attrs"` + Attrs *StringInterface `json:"attrs"` TargetID *string `json:"target_id"` TargetType *string `json:"target_type"` } @@ -174,3 +174,7 @@ type PropertyFieldSearchOpts struct { Cursor PropertyFieldSearchCursor PerPage int } + +func (pf *PropertyField) GetAttr(key string) any { + return pf.Attrs[key] +} diff --git a/webapp/channels/src/components/admin_console/list_table/list_table.scss b/webapp/channels/src/components/admin_console/list_table/list_table.scss index be073614b2..6720cff257 100644 --- a/webapp/channels/src/components/admin_console/list_table/list_table.scss +++ b/webapp/channels/src/components/admin_console/list_table/list_table.scss @@ -123,7 +123,9 @@ table.adminConsoleListTable { font-weight: 400; line-height: 20px; - &:hover { + + + &.clickable:hover { background-color: $sysCenterChannelColorWith8Alpha; cursor: pointer; @@ -207,6 +209,20 @@ table.adminConsoleListTable { tfoot { border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08); } + + .dragHandle { + position: absolute; + top: 0; + left: 0; + display: inline-flex; + width: 24px; + height: calc(100% - 1px); + color: rgba(var(--center-channel-color-rgb), 0.64); + + svg { + align-self: center; + } + } } .adminConsoleListTabletOptionalFoot, diff --git a/webapp/channels/src/components/admin_console/list_table/list_table.tsx b/webapp/channels/src/components/admin_console/list_table/list_table.tsx index 4b38b26d90..d6921c686a 100644 --- a/webapp/channels/src/components/admin_console/list_table/list_table.tsx +++ b/webapp/channels/src/components/admin_console/list_table/list_table.tsx @@ -6,10 +6,14 @@ import {flexRender} from '@tanstack/react-table'; import classNames from 'classnames'; import React, {useMemo} from 'react'; import type {AriaAttributes, MouseEvent, ReactNode} from 'react'; +import type {DropResult} from 'react-beautiful-dnd'; +import {DragDropContext, Draggable, Droppable} from 'react-beautiful-dnd'; import {FormattedMessage, defineMessages, useIntl} from 'react-intl'; import ReactSelect, {components} from 'react-select'; import type {IndicatorContainerProps, ValueType} from 'react-select'; +import {DragVerticalIcon} from '@mattermost/compass-icons/components'; + import LoadingSpinner from 'components/widgets/loading/loading_spinner'; import {Pagination} from './pagination'; @@ -56,6 +60,7 @@ export type TableMeta = { loadingState?: LoadingStates; emptyDataMessage?: ReactNode; onRowClick?: (row: string) => void; + onReorder?: (prev: number, next: number) => void; disablePrevPage?: boolean; disableNextPage?: boolean; disablePaginationControls?: boolean; @@ -117,6 +122,14 @@ export function ListTable( } } + const handleDragEnd = (result: DropResult) => { + const {source, destination} = result; + if (!destination) { + return; + } + tableMeta.onReorder?.(source.index, destination.index); + }; + const colCount = props.table.getAllColumns().length; const rowCount = props.table.getRowModel().rows.length; @@ -164,6 +177,7 @@ export function ListTable( })} disabled={header.column.getCanSort() && tableMeta.loadingState === LoadingStates.Loading} onClick={header.column.getToggleSortingHandler()} + style={{width: header.column.getSize()}} > {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} @@ -193,69 +207,104 @@ export function ListTable( ))} - - {props.table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {cell.getIsPlaceholder() ? null : flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - - {/* State where it is initially loading the data */} - {(tableMeta.loadingState === LoadingStates.Loading && rowCount === 0) && ( - - + + {(provided, snap) => ( + - - - - )} + {props.table.getRowModel().rows.map((row) => ( + + {(provided) => { + return ( + + {row.getVisibleCells().map((cell, i) => ( + + {tableMeta.onReorder && i === 0 && ( + + + + )} + {cell.getIsPlaceholder() ? null : flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); + }} + - {/* State where there is no data */} - {(tableMeta.loadingState === LoadingStates.Loaded && rowCount === 0) && ( - - - {tableMeta.emptyDataMessage || formatMessage({id: 'adminConsole.list.table.genericNoData', defaultMessage: 'No data'})} - - - )} + ))} - {/* State where there is an error loading the data */} - {tableMeta.loadingState === LoadingStates.Failed && ( - - - {formatMessage({id: 'adminConsole.list.table.genericError', defaultMessage: 'There was an error loading the data, please try again'})} - - - )} - + {provided.placeholder} + + {/* State where it is initially loading the data */} + {(tableMeta.loadingState === LoadingStates.Loading && rowCount === 0) && ( + + + + + + )} + + {/* State where there is no data */} + {(tableMeta.loadingState === LoadingStates.Loaded && rowCount === 0) && ( + + + {tableMeta.emptyDataMessage || formatMessage({id: 'adminConsole.list.table.genericNoData', defaultMessage: 'No data'})} + + + )} + + {/* State where there is an error loading the data */} + {tableMeta.loadingState === LoadingStates.Failed && ( + + + {formatMessage({id: 'adminConsole.list.table.genericError', defaultMessage: 'There was an error loading the data, please try again'})} + + + )} + + )} + + {props.table.getFooterGroups().map((footerGroup) => ( diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_table.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_table.tsx index 7581eec3d0..67acf31c76 100644 --- a/webapp/channels/src/components/admin_console/system_properties/user_properties_table.tsx +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_table.tsx @@ -7,7 +7,7 @@ import React, {useEffect, useMemo, useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import styled, {css} from 'styled-components'; -import {PlusIcon, TextBoxOutlineIcon, TrashCanOutlineIcon} from '@mattermost/compass-icons/components'; +import {MenuVariantIcon, PlusIcon, TrashCanOutlineIcon} from '@mattermost/compass-icons/components'; import type {UserPropertyField} from '@mattermost/types/properties'; import {collectionToArray} from '@mattermost/types/utilities'; @@ -30,6 +30,7 @@ type Props = { type FieldActions = { updateField: (field: UserPropertyField) => void; deleteField: (id: string) => void; + reorderField: (field: UserPropertyField, nextOrder: number) => void; } export const useUserPropertiesTable = (): SectionHook => { @@ -53,6 +54,7 @@ export const useUserPropertiesTable = (): SectionHook => { data={userPropertyFields} updateField={itemOps.update} deleteField={itemOps.delete} + reorderField={itemOps.reorder} /> {nonDeletedCount < Constants.MAX_CUSTOM_ATTRIBUTES && ( @@ -78,13 +80,14 @@ export const useUserPropertiesTable = (): SectionHook => { }; }; -export function UserPropertiesTable({data: collection, updateField, deleteField}: Props & FieldActions) { +export function UserPropertiesTable({data: collection, updateField, deleteField, reorderField}: Props & FieldActions) { const {formatMessage} = useIntl(); const data = collectionToArray(collection); const col = createColumnHelper(); const columns = useMemo>>(() => { return [ col.accessor('name', { + size: 180, header: () => { return ( @@ -150,6 +153,7 @@ export function UserPropertiesTable({data: collection, updateField, deleteField} enableSorting: false, }), col.accessor('type', { + size: 100, header: () => { return ( @@ -166,7 +170,7 @@ export function UserPropertiesTable({data: collection, updateField, deleteField} if (type === 'text') { type = ( <> - @@ -187,8 +191,17 @@ export function UserPropertiesTable({data: collection, updateField, deleteField} enableHiding: false, enableSorting: false, }), + col.display({ + id: 'options', + size: 300, + header: () => <>, + cell: () => <>, + enableHiding: false, + enableSorting: false, + }), col.display({ id: 'actions', + size: 100, header: () => { return ( @@ -202,7 +215,6 @@ export function UserPropertiesTable({data: collection, updateField, deleteField} cell: ({row}) => ( ), @@ -215,9 +227,6 @@ export function UserPropertiesTable({data: collection, updateField, deleteField} const table = useReactTable({ data, columns, - initialState: { - sorting: [], - }, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), enableSortingRemoval: false, @@ -226,6 +235,9 @@ export function UserPropertiesTable({data: collection, updateField, deleteField} meta: { tableId: 'userProperties', disablePaginationControls: true, + onReorder: (prev: number, next: number) => { + reorderField(collection.data[collection.order[prev]], next); + }, }, manualPagination: true, }); @@ -283,7 +295,7 @@ const TableWrapper = styled.div` } `; -const Actions = ({field, deleteField}: {field: UserPropertyField} & FieldActions) => { +const Actions = ({field, deleteField}: {field: UserPropertyField} & Pick) => { const {promptDelete} = useUserPropertyFieldDelete(); const {formatMessage} = useIntl(); diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.test.ts b/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.test.ts index 27f3f881e8..7c06bfebc1 100644 --- a/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.test.ts +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.test.ts @@ -3,7 +3,7 @@ import {act} from '@testing-library/react-hooks'; -import type {UserPropertyField} from '@mattermost/types/properties'; +import type {UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties'; import type {DeepPartial} from '@mattermost/types/utilities'; import {Client4} from 'mattermost-redux/client'; @@ -48,11 +48,11 @@ describe('useUserPropertyFields', () => { const deleteField = jest.spyOn(Client4, 'deleteCustomProfileAttributeField'); const createField = jest.spyOn(Client4, 'createCustomProfileAttributeField'); - const baseField = {type: 'text' as const, group_id: 'custom_profile_attributes' as const, create_at: 1736541716295, delete_at: 0, update_at: 0}; - const field0: UserPropertyField = {id: 'f0', name: 'test attribute 0', ...baseField}; - const field1: UserPropertyField = {id: 'f1', name: 'test attribute 1', ...baseField}; - const field2: UserPropertyField = {id: 'f2', name: 'test attribute 2', ...baseField}; - const field3: UserPropertyField = {id: 'f3', name: 'test attribute 3', ...baseField}; + const baseField = {type: 'text', group_id: 'custom_profile_attributes', create_at: 1736541716295, delete_at: 0, update_at: 0} as const; + const field0: UserPropertyField = {...baseField, id: 'f0', name: 'test attribute 0'}; + const field1: UserPropertyField = {...baseField, id: 'f1', name: 'test attribute 1'}; + const field2: UserPropertyField = {...baseField, id: 'f2', name: 'test attribute 2'}; + const field3: UserPropertyField = {...baseField, id: 'f3', name: 'test attribute 3'}; getFields.mockResolvedValue([field0, field1, field2, field3]); @@ -134,6 +134,59 @@ describe('useUserPropertyFields', () => { expect(fields4.data[field1.id].name).toBe('changed attribute value'); }); + it('should successfully handle reordering', async () => { + patchField.mockImplementation((id: string, patch: UserPropertyFieldPatch) => Promise.resolve({...baseField, ...patch, id, update_at: Date.now()} as UserPropertyField)); + + const {result, rerender, waitFor} = renderHookWithContext(() => { + return useUserPropertyFields(); + }, getBaseState()); + + act(() => { + jest.runAllTimers(); + }); + rerender(); + + await waitFor(() => { + const [, read] = result.current; + expect(read.loading).toBe(false); + }); + const [fields2,,, ops2] = result.current; + + act(() => { + ops2.reorder(fields2.data[field1.id], 0); + }); + rerender(); + + const [fields3, readIO3, pendingIO3] = result.current; + + // expect 2 changed fields to be in the correct order + expect(fields3.data[field1.id]?.attrs?.sort_order).toBe(0); + expect(fields3.data[field0.id]?.attrs?.sort_order).toBe(1); + expect(pendingIO3.hasChanges).toBe(true); + + await act(async () => { + const data = await pendingIO3.commit(); + if (data) { + readIO3.setData(data); + } + jest.runAllTimers(); + rerender(); + }); + + await waitFor(() => { + const [,, pending] = result.current; + expect(pending.saving).toBe(false); + }); + + expect(patchField).toHaveBeenCalledWith(field1.id, {type: 'text', name: 'test attribute 1', attrs: {sort_order: 0}}); + expect(patchField).toHaveBeenCalledWith(field0.id, {type: 'text', name: 'test attribute 0', attrs: {sort_order: 1}}); + + const [fields4,, pendingIO4] = result.current; + expect(pendingIO4.hasChanges).toBe(false); + expect(pendingIO4.error).toBe(undefined); + expect(fields4.order).toEqual(['f1', 'f0', 'f2', 'f3']); + }); + it('should successfully handle deletes', async () => { const {result, rerender, waitFor} = renderHookWithContext(() => { return useUserPropertyFields(); @@ -224,8 +277,8 @@ describe('useUserPropertyFields', () => { expect(pendingIO4.saving).toBe(false); }); - expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text'}); - expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text 2'}); + expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text', attrs: {sort_order: 4}}); + expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text 2', attrs: {sort_order: 5}}); const [fields4,,,] = result.current; expect(Object.values(fields4.data)).toEqual(expect.arrayContaining([ diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.ts b/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.ts index 1dfea816db..8667b22b85 100644 --- a/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.ts +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.ts @@ -11,6 +11,7 @@ import {collectionAddItem, collectionFromArray, collectionRemoveItem, collection import type {PartialExcept, IDMappedCollection, IDMappedObjects} from '@mattermost/types/utilities'; import {Client4} from 'mattermost-redux/client'; +import {insertWithoutDuplicates} from 'mattermost-redux/utils/array_utils'; import {generateId} from 'utils/utils'; @@ -26,7 +27,8 @@ export const useUserPropertyFields = () => { const [fieldCollection, readIO] = useThing(useMemo(() => ({ get: async () => { const data = await Client4.getCustomProfileAttributeFields(); - return collectionFromArray(data); + const sorted = data.sort((a, b) => (a.attrs?.sort_order ?? 0) - (b.attrs?.sort_order ?? 0)); + return collectionFromArray(sorted); }, // eslint-disable-next-line @typescript-eslint/no-unused-vars select: (state) => { @@ -65,7 +67,7 @@ export const useUserPropertyFields = () => { errors: {}, // start with errors cleared; don't keep stale errors }; - // delete - all + // delete await Promise.all(process.delete.map(async ({id}) => { return Client4.deleteCustomProfileAttributeField(id). then(() => { @@ -80,11 +82,11 @@ export const useUserPropertyFields = () => { }); })); - // update - all + // update await Promise.all(process.edit.map(async (pendingItem) => { - const {id, name, type} = pendingItem; + const {id, name, type, attrs} = pendingItem; - return Client4.patchCustomProfileAttributeField(id, {name, type}). + return Client4.patchCustomProfileAttributeField(id, {name, type, attrs}). then((nextItem) => { // data:updated next.data[id] = nextItem; @@ -94,12 +96,11 @@ export const useUserPropertyFields = () => { }); })); - // create - each, to preserve created/sort ordering - for (const pendingItem of process.create) { - const {id, name, type} = pendingItem; + // create + await Promise.all(process.create.map(async (pendingItem) => { + const {id, name, type, attrs} = pendingItem; - // eslint-disable-next-line no-await-in-loop - await Client4.createCustomProfileAttributeField({name, type}). + return Client4.createCustomProfileAttributeField({name, type, attrs}). then((newItem) => { // data:created (delete pending data) Reflect.deleteProperty(next.data, id); @@ -111,7 +112,7 @@ export const useUserPropertyFields = () => { catch((reason: ClientError) => { next.errors = {...next.errors, [id]: reason}; }); - } + })); if (isEmpty(next.errors)) { Reflect.deleteProperty(next, 'errors'); @@ -175,11 +176,34 @@ export const useUserPropertyFields = () => { }, create: () => { pendingIO.apply((pending) => { + const nextOrder = Object.values(pending.data).filter((x) => !isDeletePending(x)).length; const name = getIncrementedName('Text', pending); - const field = newPendingField({name, type: 'text'}); + const field = newPendingField({name, type: 'text', attrs: {sort_order: nextOrder}}); return collectionAddItem(pending, field); }); }, + reorder: ({id}, nextItemOrder) => { + pendingIO.apply((pending) => { + const nextOrder = insertWithoutDuplicates(pending.order, id, nextItemOrder); + + if (nextOrder === pending.order) { + return pending; + } + + const nextItems = Object.values(pending.data).reduce((changedItems, item) => { + const itemCurrentOrder = item.attrs?.sort_order; + const itemNextOrder = nextOrder.indexOf(item.id); + + if (itemNextOrder !== itemCurrentOrder) { + changedItems.push({...item, attrs: {sort_order: itemNextOrder}}); + } + + return changedItems; + }, []); + + return collectionReplaceItem({...pending, order: nextOrder}, ...nextItems); + }); + }, delete: (id: string) => { pendingIO.apply((pending) => { const field = pending.data[id]; diff --git a/webapp/platform/types/src/properties.ts b/webapp/platform/types/src/properties.ts index 5e70b8a53e..5b57751bef 100644 --- a/webapp/platform/types/src/properties.ts +++ b/webapp/platform/types/src/properties.ts @@ -31,5 +31,6 @@ export type UserPropertyFieldGroupID = 'custom_profile_attributes'; export type UserPropertyField = PropertyField & { type: UserPropertyFieldType; group_id: UserPropertyFieldGroupID; + attrs?: {sort_order?: number}; } -export type UserPropertyFieldPatch = Partial>; +export type UserPropertyFieldPatch = Partial>; diff --git a/webapp/platform/types/src/utilities.ts b/webapp/platform/types/src/utilities.ts index 1531e1246c..c010ecd3d1 100644 --- a/webapp/platform/types/src/utilities.ts +++ b/webapp/platform/types/src/utilities.ts @@ -90,19 +90,19 @@ export const collectionFromArray = (arr: T[] = []): IDMa current.data = {...current.data, [item.id]: item}; current.order.push(item.id); return current; - }, {data: {} as IDMappedObjects, order: []} as IDMappedCollection); + }, {data: {} as IDMappedObjects, order: [] as string[]}); }; export const collectionToArray = ({data, order}: IDMappedCollection): T[] => { return order.map((id) => data[id]); }; -export const collectionReplaceItem = (collection: IDMappedCollection, item: T) => { - return {...collection, data: {...collection.data, [item.id]: item}}; +export const collectionReplaceItem = (collection: IDMappedCollection, ...items: T[]) => { + return {...collection, data: idMappedObjectsFromArr(items, collection.data)}; }; -export const collectionAddItem = (collection: IDMappedCollection, item: T) => { - return {...collection, data: {...collection.data, [item.id]: item}, order: [...collection.order, item.id]}; +export const collectionAddItem = (collection: IDMappedCollection, ...items: T[]) => { + return {...collectionReplaceItem(collection, ...items), order: [...collection.order, ...items.map(({id}) => id)]}; }; export const collectionRemoveItem = (collection: IDMappedCollection, item: T) => { @@ -111,3 +111,7 @@ export const collectionRemoveItem = (collection: IDMappe const order = collection.order.filter((id) => id !== item.id); return {...collection, data, order}; }; + +export const idMappedObjectsFromArr = (items: T[], current?: IDMappedObjects) => { + return items.reduce((r, item) => ({...r, [item.id]: item}), {...current} as IDMappedObjects); +};