mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
MM-62548: CPA Reordering - drag and drop (#30097)
This commit is contained in:
parent
b2147476cc
commit
2182b1eaf9
@ -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
|
||||
}
|
||||
|
||||
|
@ -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()),
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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]
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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}>
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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([
|
||||
|
@ -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];
|
||||
|
@ -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'>>;
|
||||
|
@ -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>);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user