MM-62548: CPA Reordering - drag and drop (#30097)
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
b2147476cc
Коммит
2182b1eaf9
@@ -6,6 +6,7 @@ package app
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
|
||||||
"github.com/mattermost/mattermost/server/public/model"
|
"github.com/mattermost/mattermost/server/public/model"
|
||||||
"github.com/mattermost/mattermost/server/v8/channels/store"
|
"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)
|
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
|
return fields, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func TestGetCPAField(t *testing.T) {
|
|||||||
GroupID: cpaGroupID,
|
GroupID: cpaGroupID,
|
||||||
Name: "Test Field",
|
Name: "Test Field",
|
||||||
Type: model.PropertyFieldTypeText,
|
Type: model.PropertyFieldTypeText,
|
||||||
Attrs: map[string]any{"visibility": "hidden"},
|
Attrs: model.StringInterface{"visibility": "hidden"},
|
||||||
}
|
}
|
||||||
|
|
||||||
createdField, err := th.App.CreateCPAField(field)
|
createdField, err := th.App.CreateCPAField(field)
|
||||||
@@ -76,13 +76,14 @@ func TestListCPAFields(t *testing.T) {
|
|||||||
require.NoError(t, cErr)
|
require.NoError(t, cErr)
|
||||||
|
|
||||||
t.Run("should list the CPA property fields", func(t *testing.T) {
|
t.Run("should list the CPA property fields", func(t *testing.T) {
|
||||||
field1 := &model.PropertyField{
|
field1 := model.PropertyField{
|
||||||
GroupID: cpaGroupID,
|
GroupID: cpaGroupID,
|
||||||
Name: "Field 1",
|
Name: "Field 1",
|
||||||
Type: model.PropertyFieldTypeText,
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
field2 := &model.PropertyField{
|
field2 := &model.PropertyField{
|
||||||
@@ -93,23 +94,20 @@ func TestListCPAFields(t *testing.T) {
|
|||||||
_, err = th.App.Srv().propertyService.CreatePropertyField(field2)
|
_, err = th.App.Srv().propertyService.CreatePropertyField(field2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
field3 := &model.PropertyField{
|
field3 := model.PropertyField{
|
||||||
GroupID: cpaGroupID,
|
GroupID: cpaGroupID,
|
||||||
Name: "Field 3",
|
Name: "Field 3",
|
||||||
Type: model.PropertyFieldTypeText,
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
fields, appErr := th.App.ListCPAFields()
|
fields, appErr := th.App.ListCPAFields()
|
||||||
require.Nil(t, appErr)
|
require.Nil(t, appErr)
|
||||||
require.Len(t, fields, 2)
|
require.Len(t, fields, 2)
|
||||||
|
require.Equal(t, "Field 3", fields[0].Name)
|
||||||
fieldNames := []string{}
|
require.Equal(t, "Field 1", fields[1].Name)
|
||||||
for _, field := range fields {
|
|
||||||
fieldNames = append(fieldNames, field.Name)
|
|
||||||
}
|
|
||||||
require.ElementsMatch(t, []string{"Field 1", "Field 3"}, fieldNames)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +144,7 @@ func TestCreateCPAField(t *testing.T) {
|
|||||||
GroupID: cpaGroupID,
|
GroupID: cpaGroupID,
|
||||||
Name: model.NewId(),
|
Name: model.NewId(),
|
||||||
Type: model.PropertyFieldTypeText,
|
Type: model.PropertyFieldTypeText,
|
||||||
Attrs: map[string]any{"visibility": "hidden"},
|
Attrs: model.StringInterface{"visibility": "hidden"},
|
||||||
}
|
}
|
||||||
|
|
||||||
createdField, err := th.App.CreateCPAField(field)
|
createdField, err := th.App.CreateCPAField(field)
|
||||||
@@ -226,14 +224,14 @@ func TestPatchCPAField(t *testing.T) {
|
|||||||
GroupID: cpaGroupID,
|
GroupID: cpaGroupID,
|
||||||
Name: model.NewId(),
|
Name: model.NewId(),
|
||||||
Type: model.PropertyFieldTypeText,
|
Type: model.PropertyFieldTypeText,
|
||||||
Attrs: map[string]any{"visibility": "hidden"},
|
Attrs: model.StringInterface{"visibility": "hidden"},
|
||||||
}
|
}
|
||||||
createdField, err := th.App.CreateCPAField(newField)
|
createdField, err := th.App.CreateCPAField(newField)
|
||||||
require.Nil(t, err)
|
require.Nil(t, err)
|
||||||
|
|
||||||
patch := &model.PropertyFieldPatch{
|
patch := &model.PropertyFieldPatch{
|
||||||
Name: model.NewPointer("Patched name"),
|
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()),
|
TargetID: model.NewPointer(model.NewId()),
|
||||||
TargetType: model.NewPointer(model.NewId()),
|
TargetType: model.NewPointer(model.NewId()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,19 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
const CustomProfileAttributesPropertyGroupName = "custom_profile_attributes"
|
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 {
|
type PropertyFieldPatch struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Type *PropertyFieldType `json:"type"`
|
Type *PropertyFieldType `json:"type"`
|
||||||
Attrs *map[string]any `json:"attrs"`
|
Attrs *StringInterface `json:"attrs"`
|
||||||
TargetID *string `json:"target_id"`
|
TargetID *string `json:"target_id"`
|
||||||
TargetType *string `json:"target_type"`
|
TargetType *string `json:"target_type"`
|
||||||
}
|
}
|
||||||
@@ -174,3 +174,7 @@ type PropertyFieldSearchOpts struct {
|
|||||||
Cursor PropertyFieldSearchCursor
|
Cursor PropertyFieldSearchCursor
|
||||||
PerPage int
|
PerPage int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pf *PropertyField) GetAttr(key string) any {
|
||||||
|
return pf.Attrs[key]
|
||||||
|
}
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ table.adminConsoleListTable {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
|
|
||||||
|
&.clickable:hover {
|
||||||
background-color: $sysCenterChannelColorWith8Alpha;
|
background-color: $sysCenterChannelColorWith8Alpha;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
@@ -207,6 +209,20 @@ table.adminConsoleListTable {
|
|||||||
tfoot {
|
tfoot {
|
||||||
border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
|
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,
|
.adminConsoleListTabletOptionalFoot,
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import {flexRender} from '@tanstack/react-table';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, {useMemo} from 'react';
|
import React, {useMemo} from 'react';
|
||||||
import type {AriaAttributes, MouseEvent, ReactNode} 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 {FormattedMessage, defineMessages, useIntl} from 'react-intl';
|
||||||
import ReactSelect, {components} from 'react-select';
|
import ReactSelect, {components} from 'react-select';
|
||||||
import type {IndicatorContainerProps, ValueType} 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 LoadingSpinner from 'components/widgets/loading/loading_spinner';
|
||||||
|
|
||||||
import {Pagination} from './pagination';
|
import {Pagination} from './pagination';
|
||||||
@@ -56,6 +60,7 @@ export type TableMeta = {
|
|||||||
loadingState?: LoadingStates;
|
loadingState?: LoadingStates;
|
||||||
emptyDataMessage?: ReactNode;
|
emptyDataMessage?: ReactNode;
|
||||||
onRowClick?: (row: string) => void;
|
onRowClick?: (row: string) => void;
|
||||||
|
onReorder?: (prev: number, next: number) => void;
|
||||||
disablePrevPage?: boolean;
|
disablePrevPage?: boolean;
|
||||||
disableNextPage?: boolean;
|
disableNextPage?: boolean;
|
||||||
disablePaginationControls?: 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 colCount = props.table.getAllColumns().length;
|
||||||
const rowCount = props.table.getRowModel().rows.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}
|
disabled={header.column.getCanSort() && tableMeta.loadingState === LoadingStates.Loading}
|
||||||
onClick={header.column.getToggleSortingHandler()}
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
style={{width: header.column.getSize()}}
|
||||||
>
|
>
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
|
||||||
@@ -193,69 +207,104 @@ export function ListTable<TableType extends TableMandatoryTypes>(
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
{props.table.getRowModel().rows.map((row) => (
|
<Droppable droppableId='table-body'>
|
||||||
<tr
|
{(provided, snap) => (
|
||||||
id={`${rowIdPrefix}${row.original.id}`}
|
<tbody
|
||||||
key={row.id}
|
ref={provided.innerRef}
|
||||||
onClick={handleRowClick}
|
{...provided.droppableProps}
|
||||||
>
|
|
||||||
{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}
|
|
||||||
>
|
>
|
||||||
<LoadingSpinner
|
{props.table.getRowModel().rows.map((row) => (
|
||||||
text={formatMessage({id: 'adminConsole.list.table.genericLoading', defaultMessage: 'Loading'})}
|
<Draggable
|
||||||
/>
|
draggableId={row.original.id}
|
||||||
</td>
|
key={row.original.id}
|
||||||
</tr>
|
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 */}
|
{provided.placeholder}
|
||||||
{tableMeta.loadingState === LoadingStates.Failed && (
|
|
||||||
<tr>
|
{/* State where it is initially loading the data */}
|
||||||
<td
|
{(tableMeta.loadingState === LoadingStates.Loading && rowCount === 0) && (
|
||||||
colSpan={colCount}
|
<tr>
|
||||||
className='noRows'
|
<td
|
||||||
disabled={true}
|
colSpan={colCount}
|
||||||
>
|
className='noRows'
|
||||||
{formatMessage({id: 'adminConsole.list.table.genericError', defaultMessage: 'There was an error loading the data, please try again'})}
|
disabled={true}
|
||||||
</td>
|
>
|
||||||
</tr>
|
<LoadingSpinner
|
||||||
)}
|
text={formatMessage({id: 'adminConsole.list.table.genericLoading', defaultMessage: 'Loading'})}
|
||||||
</tbody>
|
/>
|
||||||
|
</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>
|
<tfoot>
|
||||||
{props.table.getFooterGroups().map((footerGroup) => (
|
{props.table.getFooterGroups().map((footerGroup) => (
|
||||||
<tr key={footerGroup.id}>
|
<tr key={footerGroup.id}>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import React, {useEffect, useMemo, useState} from 'react';
|
|||||||
import {FormattedMessage, useIntl} from 'react-intl';
|
import {FormattedMessage, useIntl} from 'react-intl';
|
||||||
import styled, {css} from 'styled-components';
|
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 type {UserPropertyField} from '@mattermost/types/properties';
|
||||||
import {collectionToArray} from '@mattermost/types/utilities';
|
import {collectionToArray} from '@mattermost/types/utilities';
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ type Props = {
|
|||||||
type FieldActions = {
|
type FieldActions = {
|
||||||
updateField: (field: UserPropertyField) => void;
|
updateField: (field: UserPropertyField) => void;
|
||||||
deleteField: (id: string) => void;
|
deleteField: (id: string) => void;
|
||||||
|
reorderField: (field: UserPropertyField, nextOrder: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUserPropertiesTable = (): SectionHook => {
|
export const useUserPropertiesTable = (): SectionHook => {
|
||||||
@@ -53,6 +54,7 @@ export const useUserPropertiesTable = (): SectionHook => {
|
|||||||
data={userPropertyFields}
|
data={userPropertyFields}
|
||||||
updateField={itemOps.update}
|
updateField={itemOps.update}
|
||||||
deleteField={itemOps.delete}
|
deleteField={itemOps.delete}
|
||||||
|
reorderField={itemOps.reorder}
|
||||||
/>
|
/>
|
||||||
{nonDeletedCount < Constants.MAX_CUSTOM_ATTRIBUTES && (
|
{nonDeletedCount < Constants.MAX_CUSTOM_ATTRIBUTES && (
|
||||||
<LinkButton onClick={itemOps.create}>
|
<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 {formatMessage} = useIntl();
|
||||||
const data = collectionToArray(collection);
|
const data = collectionToArray(collection);
|
||||||
const col = createColumnHelper<UserPropertyField>();
|
const col = createColumnHelper<UserPropertyField>();
|
||||||
const columns = useMemo<Array<ColumnDef<UserPropertyField, any>>>(() => {
|
const columns = useMemo<Array<ColumnDef<UserPropertyField, any>>>(() => {
|
||||||
return [
|
return [
|
||||||
col.accessor('name', {
|
col.accessor('name', {
|
||||||
|
size: 180,
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<ColHeaderLeft>
|
<ColHeaderLeft>
|
||||||
@@ -150,6 +153,7 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
}),
|
}),
|
||||||
col.accessor('type', {
|
col.accessor('type', {
|
||||||
|
size: 100,
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<ColHeaderLeft>
|
<ColHeaderLeft>
|
||||||
@@ -166,7 +170,7 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
|
|||||||
if (type === 'text') {
|
if (type === 'text') {
|
||||||
type = (
|
type = (
|
||||||
<>
|
<>
|
||||||
<TextBoxOutlineIcon
|
<MenuVariantIcon
|
||||||
size={18}
|
size={18}
|
||||||
color={'rgba(var(--center-channel-color-rgb), 0.64)'}
|
color={'rgba(var(--center-channel-color-rgb), 0.64)'}
|
||||||
/>
|
/>
|
||||||
@@ -187,8 +191,17 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
|
|||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
}),
|
}),
|
||||||
|
col.display({
|
||||||
|
id: 'options',
|
||||||
|
size: 300,
|
||||||
|
header: () => <></>,
|
||||||
|
cell: () => <></>,
|
||||||
|
enableHiding: false,
|
||||||
|
enableSorting: false,
|
||||||
|
}),
|
||||||
col.display({
|
col.display({
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
|
size: 100,
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<ColHeaderRight>
|
<ColHeaderRight>
|
||||||
@@ -202,7 +215,6 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
|
|||||||
cell: ({row}) => (
|
cell: ({row}) => (
|
||||||
<Actions
|
<Actions
|
||||||
field={row.original}
|
field={row.original}
|
||||||
updateField={updateField}
|
|
||||||
deleteField={deleteField}
|
deleteField={deleteField}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -215,9 +227,6 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
|
|||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
initialState: {
|
|
||||||
sorting: [],
|
|
||||||
},
|
|
||||||
getCoreRowModel: getCoreRowModel<UserPropertyField>(),
|
getCoreRowModel: getCoreRowModel<UserPropertyField>(),
|
||||||
getSortedRowModel: getSortedRowModel<UserPropertyField>(),
|
getSortedRowModel: getSortedRowModel<UserPropertyField>(),
|
||||||
enableSortingRemoval: false,
|
enableSortingRemoval: false,
|
||||||
@@ -226,6 +235,9 @@ export function UserPropertiesTable({data: collection, updateField, deleteField}
|
|||||||
meta: {
|
meta: {
|
||||||
tableId: 'userProperties',
|
tableId: 'userProperties',
|
||||||
disablePaginationControls: true,
|
disablePaginationControls: true,
|
||||||
|
onReorder: (prev: number, next: number) => {
|
||||||
|
reorderField(collection.data[collection.order[prev]], next);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
manualPagination: true,
|
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 {promptDelete} = useUserPropertyFieldDelete();
|
||||||
const {formatMessage} = useIntl();
|
const {formatMessage} = useIntl();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import {act} from '@testing-library/react-hooks';
|
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 type {DeepPartial} from '@mattermost/types/utilities';
|
||||||
|
|
||||||
import {Client4} from 'mattermost-redux/client';
|
import {Client4} from 'mattermost-redux/client';
|
||||||
@@ -48,11 +48,11 @@ describe('useUserPropertyFields', () => {
|
|||||||
const deleteField = jest.spyOn(Client4, 'deleteCustomProfileAttributeField');
|
const deleteField = jest.spyOn(Client4, 'deleteCustomProfileAttributeField');
|
||||||
const createField = jest.spyOn(Client4, 'createCustomProfileAttributeField');
|
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 baseField = {type: 'text', group_id: 'custom_profile_attributes', create_at: 1736541716295, delete_at: 0, update_at: 0} as const;
|
||||||
const field0: UserPropertyField = {id: 'f0', name: 'test attribute 0', ...baseField};
|
const field0: UserPropertyField = {...baseField, id: 'f0', name: 'test attribute 0'};
|
||||||
const field1: UserPropertyField = {id: 'f1', name: 'test attribute 1', ...baseField};
|
const field1: UserPropertyField = {...baseField, id: 'f1', name: 'test attribute 1'};
|
||||||
const field2: UserPropertyField = {id: 'f2', name: 'test attribute 2', ...baseField};
|
const field2: UserPropertyField = {...baseField, id: 'f2', name: 'test attribute 2'};
|
||||||
const field3: UserPropertyField = {id: 'f3', name: 'test attribute 3', ...baseField};
|
const field3: UserPropertyField = {...baseField, id: 'f3', name: 'test attribute 3'};
|
||||||
|
|
||||||
getFields.mockResolvedValue([field0, field1, field2, field3]);
|
getFields.mockResolvedValue([field0, field1, field2, field3]);
|
||||||
|
|
||||||
@@ -134,6 +134,59 @@ describe('useUserPropertyFields', () => {
|
|||||||
expect(fields4.data[field1.id].name).toBe('changed attribute value');
|
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 () => {
|
it('should successfully handle deletes', async () => {
|
||||||
const {result, rerender, waitFor} = renderHookWithContext(() => {
|
const {result, rerender, waitFor} = renderHookWithContext(() => {
|
||||||
return useUserPropertyFields();
|
return useUserPropertyFields();
|
||||||
@@ -224,8 +277,8 @@ describe('useUserPropertyFields', () => {
|
|||||||
expect(pendingIO4.saving).toBe(false);
|
expect(pendingIO4.saving).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text'});
|
expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text', attrs: {sort_order: 4}});
|
||||||
expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text 2'});
|
expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text 2', attrs: {sort_order: 5}});
|
||||||
|
|
||||||
const [fields4,,,] = result.current;
|
const [fields4,,,] = result.current;
|
||||||
expect(Object.values(fields4.data)).toEqual(expect.arrayContaining([
|
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 type {PartialExcept, IDMappedCollection, IDMappedObjects} from '@mattermost/types/utilities';
|
||||||
|
|
||||||
import {Client4} from 'mattermost-redux/client';
|
import {Client4} from 'mattermost-redux/client';
|
||||||
|
import {insertWithoutDuplicates} from 'mattermost-redux/utils/array_utils';
|
||||||
|
|
||||||
import {generateId} from 'utils/utils';
|
import {generateId} from 'utils/utils';
|
||||||
|
|
||||||
@@ -26,7 +27,8 @@ export const useUserPropertyFields = () => {
|
|||||||
const [fieldCollection, readIO] = useThing<UserPropertyFields>(useMemo(() => ({
|
const [fieldCollection, readIO] = useThing<UserPropertyFields>(useMemo(() => ({
|
||||||
get: async () => {
|
get: async () => {
|
||||||
const data = await Client4.getCustomProfileAttributeFields();
|
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
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
select: (state) => {
|
select: (state) => {
|
||||||
@@ -65,7 +67,7 @@ export const useUserPropertyFields = () => {
|
|||||||
errors: {}, // start with errors cleared; don't keep stale errors
|
errors: {}, // start with errors cleared; don't keep stale errors
|
||||||
};
|
};
|
||||||
|
|
||||||
// delete - all
|
// delete
|
||||||
await Promise.all(process.delete.map(async ({id}) => {
|
await Promise.all(process.delete.map(async ({id}) => {
|
||||||
return Client4.deleteCustomProfileAttributeField(id).
|
return Client4.deleteCustomProfileAttributeField(id).
|
||||||
then(() => {
|
then(() => {
|
||||||
@@ -80,11 +82,11 @@ export const useUserPropertyFields = () => {
|
|||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// update - all
|
// update
|
||||||
await Promise.all(process.edit.map(async (pendingItem) => {
|
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) => {
|
then((nextItem) => {
|
||||||
// data:updated
|
// data:updated
|
||||||
next.data[id] = nextItem;
|
next.data[id] = nextItem;
|
||||||
@@ -94,12 +96,11 @@ export const useUserPropertyFields = () => {
|
|||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// create - each, to preserve created/sort ordering
|
// create
|
||||||
for (const pendingItem of process.create) {
|
await Promise.all(process.create.map(async (pendingItem) => {
|
||||||
const {id, name, type} = pendingItem;
|
const {id, name, type, attrs} = pendingItem;
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
return Client4.createCustomProfileAttributeField({name, type, attrs}).
|
||||||
await Client4.createCustomProfileAttributeField({name, type}).
|
|
||||||
then((newItem) => {
|
then((newItem) => {
|
||||||
// data:created (delete pending data)
|
// data:created (delete pending data)
|
||||||
Reflect.deleteProperty(next.data, id);
|
Reflect.deleteProperty(next.data, id);
|
||||||
@@ -111,7 +112,7 @@ export const useUserPropertyFields = () => {
|
|||||||
catch((reason: ClientError) => {
|
catch((reason: ClientError) => {
|
||||||
next.errors = {...next.errors, [id]: reason};
|
next.errors = {...next.errors, [id]: reason};
|
||||||
});
|
});
|
||||||
}
|
}));
|
||||||
|
|
||||||
if (isEmpty(next.errors)) {
|
if (isEmpty(next.errors)) {
|
||||||
Reflect.deleteProperty(next, 'errors');
|
Reflect.deleteProperty(next, 'errors');
|
||||||
@@ -175,11 +176,34 @@ export const useUserPropertyFields = () => {
|
|||||||
},
|
},
|
||||||
create: () => {
|
create: () => {
|
||||||
pendingIO.apply((pending) => {
|
pendingIO.apply((pending) => {
|
||||||
|
const nextOrder = Object.values(pending.data).filter((x) => !isDeletePending(x)).length;
|
||||||
const name = getIncrementedName('Text', pending);
|
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);
|
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) => {
|
delete: (id: string) => {
|
||||||
pendingIO.apply((pending) => {
|
pendingIO.apply((pending) => {
|
||||||
const field = pending.data[id];
|
const field = pending.data[id];
|
||||||
|
|||||||
@@ -31,5 +31,6 @@ export type UserPropertyFieldGroupID = 'custom_profile_attributes';
|
|||||||
export type UserPropertyField = PropertyField & {
|
export type UserPropertyField = PropertyField & {
|
||||||
type: UserPropertyFieldType;
|
type: UserPropertyFieldType;
|
||||||
group_id: UserPropertyFieldGroupID;
|
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.data = {...current.data, [item.id]: item};
|
||||||
current.order.push(item.id);
|
current.order.push(item.id);
|
||||||
return current;
|
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[] => {
|
export const collectionToArray = <T extends {id: string}>({data, order}: IDMappedCollection<T>): T[] => {
|
||||||
return order.map((id) => data[id]);
|
return order.map((id) => data[id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const collectionReplaceItem = <T extends {id: string}>(collection: IDMappedCollection<T>, item: T) => {
|
export const collectionReplaceItem = <T extends {id: string}>(collection: IDMappedCollection<T>, ...items: T[]) => {
|
||||||
return {...collection, data: {...collection.data, [item.id]: item}};
|
return {...collection, data: idMappedObjectsFromArr(items, collection.data)};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const collectionAddItem = <T extends {id: string}>(collection: IDMappedCollection<T>, item: T) => {
|
export const collectionAddItem = <T extends {id: string}>(collection: IDMappedCollection<T>, ...items: T[]) => {
|
||||||
return {...collection, data: {...collection.data, [item.id]: item}, order: [...collection.order, item.id]};
|
return {...collectionReplaceItem(collection, ...items), order: [...collection.order, ...items.map(({id}) => id)]};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const collectionRemoveItem = <T extends {id: string}>(collection: IDMappedCollection<T>, item: T) => {
|
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);
|
const order = collection.order.filter((id) => id !== item.id);
|
||||||
return {...collection, data, order};
|
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>);
|
||||||
|
};
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user