MM-62548: CPA Reordering - drag and drop (#30097)

This commit is contained in:
Caleb Roseland 2025-02-13 17:09:35 -06:00 committed by GitHub
parent b2147476cc
commit 2182b1eaf9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 292 additions and 110 deletions

View File

@ -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
}

View File

@ -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()),
}

View File

@ -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)
}

View File

@ -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]
}

View File

@ -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,

View File

@ -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<TableType extends TableMandatoryTypes>(
}
}
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<TableType extends TableMandatoryTypes>(
})}
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<TableType extends TableMandatoryTypes>(
</tr>
))}
</thead>
<tbody>
{props.table.getRowModel().rows.map((row) => (
<tr
id={`${rowIdPrefix}${row.original.id}`}
key={row.id}
onClick={handleRowClick}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
id={`${cellIdPrefix}${cell.id}`}
headers={`${headerIdPrefix}${cell.column.id}`}
className={classNames(`${cell.column.id}`, {
[PINNED_CLASS]: cell.column.getCanPin(),
})}
>
{cell.getIsPlaceholder() ? null : flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
{/* State where it is initially loading the data */}
{(tableMeta.loadingState === LoadingStates.Loading && rowCount === 0) && (
<tr>
<td
colSpan={colCount}
className='noRows'
disabled={true}
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId='table-body'>
{(provided, snap) => (
<tbody
ref={provided.innerRef}
{...provided.droppableProps}
>
<LoadingSpinner
text={formatMessage({id: 'adminConsole.list.table.genericLoading', defaultMessage: 'Loading'})}
/>
</td>
</tr>
)}
{props.table.getRowModel().rows.map((row) => (
<Draggable
draggableId={row.original.id}
key={row.original.id}
index={row.index}
isDragDisabled={!tableMeta.onReorder}
>
{(provided) => {
return (
<tr
id={`${rowIdPrefix}${row.original.id}`}
key={row.id}
onClick={handleRowClick}
className={classNames({clickable: Boolean(tableMeta.onRowClick) && !snap.isDraggingOver})}
ref={provided.innerRef}
{...provided.draggableProps}
>
{row.getVisibleCells().map((cell, i) => (
<td
key={cell.id}
id={`${cellIdPrefix}${cell.id}`}
headers={`${headerIdPrefix}${cell.column.id}`}
className={classNames(`${cell.column.id}`, {
[PINNED_CLASS]: cell.column.getCanPin(),
})}
style={{width: cell.column.getSize()}}
>
{tableMeta.onReorder && i === 0 && (
<span
className='dragHandle'
{...provided.dragHandleProps}
>
<DragVerticalIcon size={18}/>
</span>
)}
{cell.getIsPlaceholder() ? null : flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
}}
</Draggable>
{/* State where there is no data */}
{(tableMeta.loadingState === LoadingStates.Loaded && rowCount === 0) && (
<tr>
<td
colSpan={colCount}
className='noRows'
disabled={true}
>
{tableMeta.emptyDataMessage || formatMessage({id: 'adminConsole.list.table.genericNoData', defaultMessage: 'No data'})}
</td>
</tr>
)}
))}
{/* State where there is an error loading the data */}
{tableMeta.loadingState === LoadingStates.Failed && (
<tr>
<td
colSpan={colCount}
className='noRows'
disabled={true}
>
{formatMessage({id: 'adminConsole.list.table.genericError', defaultMessage: 'There was an error loading the data, please try again'})}
</td>
</tr>
)}
</tbody>
{provided.placeholder}
{/* State where it is initially loading the data */}
{(tableMeta.loadingState === LoadingStates.Loading && rowCount === 0) && (
<tr>
<td
colSpan={colCount}
className='noRows'
disabled={true}
>
<LoadingSpinner
text={formatMessage({id: 'adminConsole.list.table.genericLoading', defaultMessage: 'Loading'})}
/>
</td>
</tr>
)}
{/* State where there is no data */}
{(tableMeta.loadingState === LoadingStates.Loaded && rowCount === 0) && (
<tr>
<td
colSpan={colCount}
className='noRows'
disabled={true}
>
{tableMeta.emptyDataMessage || formatMessage({id: 'adminConsole.list.table.genericNoData', defaultMessage: 'No data'})}
</td>
</tr>
)}
{/* State where there is an error loading the data */}
{tableMeta.loadingState === LoadingStates.Failed && (
<tr>
<td
colSpan={colCount}
className='noRows'
disabled={true}
>
{formatMessage({id: 'adminConsole.list.table.genericError', defaultMessage: 'There was an error loading the data, please try again'})}
</td>
</tr>
)}
</tbody>
)}
</Droppable>
</DragDropContext>
<tfoot>
{props.table.getFooterGroups().map((footerGroup) => (
<tr key={footerGroup.id}>

View File

@ -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 && (
<LinkButton onClick={itemOps.create}>
@ -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<UserPropertyField>();
const columns = useMemo<Array<ColumnDef<UserPropertyField, any>>>(() => {
return [
col.accessor('name', {
size: 180,
header: () => {
return (
<ColHeaderLeft>
@ -150,6 +153,7 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
enableSorting: false,
}),
col.accessor('type', {
size: 100,
header: () => {
return (
<ColHeaderLeft>
@ -166,7 +170,7 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
if (type === 'text') {
type = (
<>
<TextBoxOutlineIcon
<MenuVariantIcon
size={18}
color={'rgba(var(--center-channel-color-rgb), 0.64)'}
/>
@ -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 (
<ColHeaderRight>
@ -202,7 +215,6 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
cell: ({row}) => (
<Actions
field={row.original}
updateField={updateField}
deleteField={deleteField}
/>
),
@ -215,9 +227,6 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
const table = useReactTable({
data,
columns,
initialState: {
sorting: [],
},
getCoreRowModel: getCoreRowModel<UserPropertyField>(),
getSortedRowModel: getSortedRowModel<UserPropertyField>(),
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<FieldActions, 'deleteField'>) => {
const {promptDelete} = useUserPropertyFieldDelete();
const {formatMessage} = useIntl();

View File

@ -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([

View File

@ -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<UserPropertyFields>(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<UserPropertyField[]>((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];

View File

@ -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<Pick<UserPropertyField, 'name' | 'attrs' | 'type'>>;
export type UserPropertyFieldPatch = Partial<Pick<UserPropertyField, 'name' | 'attrs' | 'type' | 'attrs'>>;

View File

@ -90,19 +90,19 @@ export const collectionFromArray = <T extends {id: string}>(arr: T[] = []): IDMa
current.data = {...current.data, [item.id]: item};
current.order.push(item.id);
return current;
}, {data: {} as IDMappedObjects<T>, order: []} as IDMappedCollection<T>);
}, {data: {} as IDMappedObjects<T>, order: [] as string[]});
};
export const collectionToArray = <T extends {id: string}>({data, order}: IDMappedCollection<T>): T[] => {
return order.map((id) => data[id]);
};
export const collectionReplaceItem = <T extends {id: string}>(collection: IDMappedCollection<T>, item: T) => {
return {...collection, data: {...collection.data, [item.id]: item}};
export const collectionReplaceItem = <T extends {id: string}>(collection: IDMappedCollection<T>, ...items: T[]) => {
return {...collection, data: idMappedObjectsFromArr(items, collection.data)};
};
export const collectionAddItem = <T extends {id: string}>(collection: IDMappedCollection<T>, item: T) => {
return {...collection, data: {...collection.data, [item.id]: item}, order: [...collection.order, item.id]};
export const collectionAddItem = <T extends {id: string}>(collection: IDMappedCollection<T>, ...items: T[]) => {
return {...collectionReplaceItem(collection, ...items), order: [...collection.order, ...items.map(({id}) => id)]};
};
export const collectionRemoveItem = <T extends {id: string}>(collection: IDMappedCollection<T>, item: T) => {
@ -111,3 +111,7 @@ export const collectionRemoveItem = <T extends {id: string}>(collection: IDMappe
const order = collection.order.filter((id) => id !== item.id);
return {...collection, data, order};
};
export const idMappedObjectsFromArr = <T extends {id: string}>(items: T[], current?: IDMappedObjects<T>) => {
return items.reduce((r, item) => ({...r, [item.id]: item}), {...current} as IDMappedObjects<T>);
};