diff --git a/server/public/model/custom_profile_attributes.go b/server/public/model/custom_profile_attributes.go index 57fa514bd7..51771b3ffc 100644 --- a/server/public/model/custom_profile_attributes.go +++ b/server/public/model/custom_profile_attributes.go @@ -123,7 +123,7 @@ type CPAField struct { type CPAAttrs struct { Visibility string `json:"visibility"` - SortOrder int `json:"sort_order"` + SortOrder float64 `json:"sort_order"` Options PropertyOptions[*CustomProfileAttributesSelectOption] `json:"options"` ValueType string `json:"value_type"` LDAP string `json:"ldap"` diff --git a/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.tsx b/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.tsx index a8098eaed9..940b05bd10 100644 --- a/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.tsx +++ b/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.tsx @@ -537,7 +537,7 @@ function SharedChannelRemotesTable(props: {data: SharedChannelRemoteRow[]; filte } const TableWrapper = styled.div` - table.adminConsoleListTable { + table.adminConsoleListTable.sharedChannelRemotes { td, th { &:after, &:before { @@ -554,8 +554,8 @@ const TableWrapper = styled.div` tr { border-top: none; td { - padding-block-end: 0; - padding-block-start: 0; + padding-block-end: 8px; + padding-block-start: 8px; } } diff --git a/webapp/channels/src/components/admin_console/system_properties/controls.tsx b/webapp/channels/src/components/admin_console/system_properties/controls.tsx index 9d9781220d..8687c7eecb 100644 --- a/webapp/channels/src/components/admin_console/system_properties/controls.tsx +++ b/webapp/channels/src/components/admin_console/system_properties/controls.tsx @@ -43,15 +43,26 @@ export const AdminWrapper = (props: {children: ReactNode}) => { ); }; -export const FieldInput = styled.input.attrs({className: 'form-control secure-connections-input'})<{$deleted?: boolean; $strong?: boolean; $borderless?: boolean}>` - font-weight: normal; - - ${({$borderless}) => $borderless && css` - && { +export const BorderlessInput = styled.input.attrs({className: 'Input form-control'})<{$deleted?: boolean; $strong?: boolean}>` + && { + height: 40px; + border-color: transparent; + border-top: 0; + background: none; + box-shadow: none; + &:hover, + &:focus { border-color: transparent; box-shadow: none; } - `}; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.04) + } + &:focus { + background: rgba(var(--button-bg-rgb), 0.08); + } + } ${({$deleted}) => $deleted && css` && { diff --git a/webapp/channels/src/components/admin_console/system_properties/system_properties.test.tsx b/webapp/channels/src/components/admin_console/system_properties/system_properties.test.tsx index 3d5105dffb..523ea98d9e 100644 --- a/webapp/channels/src/components/admin_console/system_properties/system_properties.test.tsx +++ b/webapp/channels/src/components/admin_console/system_properties/system_properties.test.tsx @@ -39,11 +39,25 @@ function getBaseState(): DeepPartial { describe('SystemProperties', () => { const getFields = jest.spyOn(Client4, 'getCustomProfileAttributeFields'); - 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: UserPropertyField = { + id: 'test-id', + name: 'Test Field', + type: 'text' as const, + group_id: 'custom_profile_attributes', + create_at: 1736541716295, + delete_at: 0, + update_at: 0, + attrs: { + sort_order: 0, + visibility: 'when_set' as const, + value_type: '', + }, + }; + + const field0: UserPropertyField = {...baseField, id: 'test-id-0', name: 'test attribute 0'}; + const field1: UserPropertyField = {...baseField, id: 'test-id-1', name: 'test attribute 1'}; + const field2: UserPropertyField = {...baseField, id: 'test-id-2', name: 'test attribute 2'}; + const field3: UserPropertyField = {...baseField, id: 'test-id-3', name: 'test attribute 3'}; getFields.mockResolvedValue([field0, field1, field2, field3]); diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_delete_modal.test.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_delete_modal.test.tsx new file mode 100644 index 0000000000..010551b0a6 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_delete_modal.test.tsx @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen, fireEvent} from '@testing-library/react'; +import React from 'react'; + +import type {UserPropertyField} from '@mattermost/types/properties'; + +import {openModal} from 'actions/views/modals'; + +import {renderWithContext, renderHookWithContext} from 'tests/react_testing_utils'; +import {ModalIdentifiers} from 'utils/constants'; + +import RemoveUserPropertyFieldModal, {useUserPropertyFieldDelete} from './user_properties_delete_modal'; + +jest.mock('actions/views/modals', () => ({ + openModal: jest.fn(() => ({type: 'MOCK_OPEN_MODAL'})), +})); + +describe('RemoveUserPropertyFieldModal', () => { + const onConfirm = jest.fn(); + const onCancel = jest.fn(); + const onExited = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with the correct field name', () => { + renderWithContext( + , + ); + + expect(screen.getByText('Delete Test Field property')).toBeInTheDocument(); + expect(screen.getByText('Deleting this property will remove all user-defined values associated with it.')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + it('calls onConfirm when confirm button is clicked', () => { + renderWithContext( + , + ); + + fireEvent.click(screen.getByText('Delete')); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('calls onCancel when cancel button is clicked', () => { + renderWithContext( + , + ); + + fireEvent.click(screen.getByText('Cancel')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); + +describe('useUserPropertyFieldDelete', () => { + const baseField: UserPropertyField = { + id: 'test-id', + name: 'Test Field', + type: 'text', + group_id: 'custom_profile_attributes', + create_at: 1736541716295, + delete_at: 0, + update_at: 0, + attrs: { + sort_order: 0, + visibility: 'when_set', + value_type: '', + }, + }; + + it('calls openModal with correct params when promptDelete is called', () => { + const {result} = renderHookWithContext(() => useUserPropertyFieldDelete()); + + result.current.promptDelete(baseField); + + expect(openModal).toHaveBeenCalledWith({ + modalId: ModalIdentifiers.USER_PROPERTY_FIELD_DELETE, + dialogType: RemoveUserPropertyFieldModal, + dialogProps: { + name: baseField.name, + onConfirm: expect.any(Function), + }, + }); + }); + + it('returns a promise that resolves when onConfirm is called', async () => { + const {result} = renderHookWithContext(() => useUserPropertyFieldDelete()); + + // Create a mock implementation that immediately calls the onConfirm callback + (openModal as jest.Mock).mockImplementationOnce(({dialogProps}) => { + dialogProps.onConfirm(); + return {type: 'MOCK_OPEN_MODAL'}; + }); + + const promise = result.current.promptDelete(baseField); + + await expect(promise).resolves.toBe(true); + }); +}); diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.scss b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.scss new file mode 100644 index 0000000000..79dc81ae93 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.scss @@ -0,0 +1,32 @@ +.user-property-field-dotmenu-menu-button { + height: 40px; + justify-content: start; + border-color: transparent; + border-radius: 0; + box-shadow: none; + font-weight: normal; + &:hover, + &:focus { + border-color: transparent; + box-shadow: none; + } + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.04) + } + &:focus, + &[aria-expanded="true"] { + background: rgba(var(--button-bg-rgb), 0.08); + } + + &.deleted { + color: #D24B4E; + text-decoration: line-through; + } + + &.strong { + font-size: 14px; + font-style: normal; + font-weight: 600; + } +} diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx new file mode 100644 index 0000000000..ddd90366b2 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx @@ -0,0 +1,156 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, screen, waitFor} from '@testing-library/react'; +import React from 'react'; + +import type {UserPropertyField} from '@mattermost/types/properties'; + +import ModalController from 'components/modal_controller'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import DotMenu from './user_properties_dot_menu'; + +describe('UserPropertyDotMenu', () => { + const baseField: UserPropertyField = { + id: 'test-id', + name: 'Test Field', + type: 'text', + group_id: 'custom_profile_attributes', + create_at: 1736541716295, + delete_at: 0, + update_at: 0, + attrs: { + sort_order: 0, + visibility: 'when_set', + value_type: '', + }, + }; + + const updateField = jest.fn(); + const deleteField = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const renderComponent = (field: UserPropertyField = baseField) => { + return renderWithContext( + ( +
+ + +
+ ), + ); + }; + + it('renders dot menu button', () => { + renderComponent(); + + const menuButton = screen.getByTestId(`user-property-field_dotmenu-${baseField.id}`); + expect(menuButton).toBeInTheDocument(); + }); + + it('disables menu button when field is marked for deletion', () => { + const deletedField = { + ...baseField, + delete_at: 123456789, + }; + + renderComponent(deletedField); + + const menuButton = screen.getByTestId(`user-property-field_dotmenu-${baseField.id}`); + expect(menuButton).toBeDisabled(); + }); + + it('shows correct visibility option based on field setting', async () => { + renderComponent(); + + // Open the menu + const menuButton = screen.getByTestId(`user-property-field_dotmenu-${baseField.id}`); + fireEvent.click(menuButton); + + // Verify the current visibility option is shown + expect(screen.getByText('Hide when empty')).toBeInTheDocument(); + }); + + it('updates visibility when selecting a different option', async () => { + renderComponent(); + + // Open the menu + const menuButton = screen.getByTestId(`user-property-field_dotmenu-${baseField.id}`); + fireEvent.click(menuButton); + + // Open the visibility submenu + const visibilityMenuItem = screen.getByRole('menuitem', {name: /Visibility/}); + fireEvent.mouseOver(visibilityMenuItem); + + // Click "Always show" option + const alwaysShowOption = screen.getByRole('menuitemradio', {name: /Always show/}); + fireEvent.click(alwaysShowOption); + + // Verify the field was updated with the new visibility + expect(updateField).toHaveBeenCalledWith({ + ...baseField, + attrs: { + ...baseField.attrs, + visibility: 'always', + }, + }); + }); + + it('handles field deletion with confirmation when field exists in DB', async () => { + renderComponent(); + + // Open the menu + const menuButton = screen.getByTestId(`user-property-field_dotmenu-${baseField.id}`); + fireEvent.click(menuButton); + + // Click delete option + const deleteOption = screen.getByRole('menuitem', {name: /Delete property/}); + fireEvent.click(deleteOption); + + await waitFor(() => { + // Verify the delete modal is shown + expect(screen.getByText('Delete Test Field property')).toBeInTheDocument(); + }); + + // click delete confirm button + const deleteConfirmButton = screen.getByRole('button', {name: /Delete/}); + fireEvent.click(deleteConfirmButton); + + await waitFor(() => { + // Verify deleteField was called + // promptDelete from the mock will resolve to true, triggering deleteField + expect(deleteField).toHaveBeenCalledWith(baseField.id); + }); + }); + + it('skips confirmation when deleting a newly created field', async () => { + const pendingField = { + ...baseField, + create_at: 0, // Mark as pending creation + }; + + renderComponent(pendingField); + + // Open the menu + const menuButton = screen.getByTestId(`user-property-field_dotmenu-${pendingField.id}`); + fireEvent.click(menuButton); + + // Click delete option + const deleteOption = screen.getByRole('menuitem', {name: /Delete property/}); + fireEvent.click(deleteOption); + + await waitFor(() => { + // Verify deleteField was called + expect(deleteField).toHaveBeenCalledWith(pendingField.id); + }); + }); +}); diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx new file mode 100644 index 0000000000..098cadf8b2 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx @@ -0,0 +1,181 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {CheckIcon, ChevronRightIcon, DotsHorizontalIcon, EyeOutlineIcon, TrashCanOutlineIcon} from '@mattermost/compass-icons/components'; +import type {FieldVisibility, UserPropertyField} from '@mattermost/types/properties'; + +import * as Menu from 'components/menu'; + +import './user_properties_dot_menu.scss'; +import {useUserPropertyFieldDelete} from './user_properties_delete_modal'; +import {isCreatePending} from './user_properties_utils'; + +type Props = { + field: UserPropertyField; + updateField: (field: UserPropertyField) => void; + deleteField: (id: string) => void; +} + +const menuId = 'user-property-field_dotmenu'; + +const DotMenu = ({ + field, + updateField, + deleteField, +}: Props) => { + const {promptDelete} = useUserPropertyFieldDelete(); + + const handleDelete = () => { + if (isCreatePending(field)) { + // skip prompt when field is pending creation + deleteField(field.id); + } else { + promptDelete(field).then(() => deleteField(field.id)); + } + }; + + const handleVisibilityChange = (visibility: FieldVisibility) => { + updateField({...field, attrs: {...field.attrs, visibility}}); + }; + + let selectedVisibilityLabel; + + if (field.attrs.visibility === 'always') { + selectedVisibilityLabel = ( + + ); + } else if (field.attrs.visibility === 'when_set') { + selectedVisibilityLabel = ( + + ); + } else if (field.attrs.visibility === 'hidden') { + selectedVisibilityLabel = ( + + ); + } + + return ( + + + + ), + dataTestId: `${menuId}-${field.id}`, + disabled: field.delete_at !== 0, + }} + menu={{ + id: `${menuId}-menu`, + 'aria-label': 'Select an action', + className: 'user-property-field-dotmenu-menu', + }} + > + } + labels={( + + )} + trailingElements={( + <> + {selectedVisibilityLabel} + + + )} + forceOpenOnLeft={false} + > + handleVisibilityChange('always')} + labels={( + + )} + trailingElements={field.attrs.visibility === 'always' && ( + + )} + /> + handleVisibilityChange('when_set')} + labels={( + + )} + trailingElements={field.attrs.visibility === 'when_set' && ( + + )} + /> + handleVisibilityChange('hidden')} + labels={( + + )} + trailingElements={field.attrs.visibility === 'hidden' && ( + + )} + /> + + + + )} + leadingElement={} + /> + + ); +}; + +export default DotMenu; diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_table.test.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_table.test.tsx new file mode 100644 index 0000000000..024992e104 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_table.test.tsx @@ -0,0 +1,163 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, screen, waitFor} from '@testing-library/react'; +import React from 'react'; + +import type {UserPropertyField} from '@mattermost/types/properties'; +import {collectionFromArray} from '@mattermost/types/utilities'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import {UserPropertiesTable} from './user_properties_table'; + +jest.mock('./user_properties_delete_modal', () => ({ + useUserPropertyFieldDelete: jest.fn(() => ({ + promptDelete: jest.fn().mockResolvedValue(true), + })), +})); + +describe('UserPropertiesTable', () => { + const baseFields: UserPropertyField[] = [ + { + id: 'field1', + name: 'Field 1', + type: 'text', + group_id: 'custom_profile_attributes', + create_at: 1736541716295, + delete_at: 0, + update_at: 0, + attrs: { + sort_order: 0, + visibility: 'when_set', + value_type: '', + }, + }, + { + id: 'field2', + name: 'Field 2', + type: 'select', + group_id: 'custom_profile_attributes', + create_at: 1736541716295, + delete_at: 0, + update_at: 0, + attrs: { + sort_order: 1, + visibility: 'when_set', + value_type: '', + options: [ + {id: 'option1', name: 'Option 1'}, + {id: 'option2', name: 'Option 2'}, + ], + }, + }, + ]; + + const updateField = jest.fn(); + const deleteField = jest.fn(); + const reorderField = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const renderComponent = (fields = baseFields) => { + const collection = collectionFromArray(fields); + + return renderWithContext( + , + ); + }; + + it('renders table with correct property fields', () => { + renderComponent(); + + // Check column headers + expect(screen.getByText('Property')).toBeInTheDocument(); + expect(screen.getByText('Type')).toBeInTheDocument(); + expect(screen.getByText('Values')).toBeInTheDocument(); + expect(screen.getByText('Actions')).toBeInTheDocument(); + + // Check field values + expect(screen.getByDisplayValue('Field 1')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Field 2')).toBeInTheDocument(); + expect(screen.getByText('Text')).toBeInTheDocument(); + expect(screen.getByText('Select')).toBeInTheDocument(); + }); + + it('allows editing field names', () => { + renderComponent(); + + const field1Input = screen.getByDisplayValue('Field 1'); + fireEvent.change(field1Input, {target: {value: 'Edited Field 1'}}); + fireEvent.blur(field1Input); + + expect(updateField).toHaveBeenCalledWith({ + ...baseFields[0], + name: 'Edited Field 1', + }); + }); + + it('shows type selection menu', () => { + renderComponent(); + + // Check the type selectors exist + expect(screen.getByText('Text')).toBeInTheDocument(); + expect(screen.getByText('Select')).toBeInTheDocument(); + }); + + it('shows dot menu for actions', () => { + renderComponent(); + + // Check that dot menus exist + const dotMenuButtons = screen.getAllByTestId(/user-property-field_dotmenu-/); + expect(dotMenuButtons).toHaveLength(2); + }); + + it('handles deleted fields correctly', () => { + const deletedFields = [ + ...baseFields, + { + ...baseFields[0], + id: 'deleted-field', + name: 'Deleted Field', + delete_at: 123456789, + }, + ]; + + renderComponent(deletedFields); + + // Deleted field should still be in the table but have disabled inputs + const deletedInput = screen.getByDisplayValue('Deleted Field'); + expect(deletedInput).toBeDisabled(); + }); + + it('displays validation warnings', async () => { + const fields = [...baseFields]; + const collection = collectionFromArray(fields); + + // Add validation warnings + collection.warnings = { + field1: {name: 'user_properties.validation.name_required'}, + }; + + renderWithContext( + , + ); + + // Validation error should be shown + await waitFor(() => { + expect(screen.getByText('Please enter a property name.')).toBeInTheDocument(); + }); + }); +}); 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 67acf31c76..d70cce0303 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 @@ -5,9 +5,9 @@ import {createColumnHelper, getCoreRowModel, getSortedRowModel, useReactTable, t import type {ReactNode} from 'react'; import React, {useEffect, useMemo, useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; -import styled, {css} from 'styled-components'; +import styled from 'styled-components'; -import {MenuVariantIcon, PlusIcon, TrashCanOutlineIcon} from '@mattermost/compass-icons/components'; +import {PlusIcon} from '@mattermost/compass-icons/components'; import type {UserPropertyField} from '@mattermost/types/properties'; import {collectionToArray} from '@mattermost/types/utilities'; @@ -15,11 +15,13 @@ import LoadingScreen from 'components/loading_screen'; import Constants from 'utils/constants'; -import {DangerText, FieldDeleteButton, FieldInput, LinkButton} from './controls'; +import {DangerText, BorderlessInput, LinkButton} from './controls'; import type {SectionHook} from './section_utils'; -import {useUserPropertyFieldDelete} from './user_properties_delete_modal'; +import DotMenu from './user_properties_dot_menu'; +import SelectType from './user_properties_type_menu'; import type {UserPropertyFields} from './user_properties_utils'; import {isCreatePending, useUserPropertyFields, ValidationWarningNameRequired, ValidationWarningNameTaken, ValidationWarningNameUnique} from './user_properties_utils'; +import UserPropertyValues from './user_properties_values'; import {AdminConsoleListTable} from '../list_table'; @@ -132,7 +134,7 @@ export function UserPropertiesTable({data: collection, updateField, deleteField, return ( <> - ); }, - cell: ({getValue, row}) => { - let type = getValue(); - - if (type === 'text') { - type = ( - <> - - - - ); - } - + cell: ({row}) => { return ( - - {type} - + ); }, enableHiding: false, @@ -194,14 +180,28 @@ export function UserPropertiesTable({data: collection, updateField, deleteField, col.display({ id: 'options', size: 300, - header: () => <>, - cell: () => <>, + header: () => ( + + + + ), + cell: ({row}) => ( + <> + + + ), enableHiding: false, enableSorting: false, }), col.display({ id: 'actions', - size: 100, + size: 40, header: () => { return ( @@ -213,10 +213,13 @@ export function UserPropertiesTable({data: collection, updateField, deleteField, ); }, cell: ({row}) => ( - + + + ), enableHiding: false, enableSorting: false, @@ -276,12 +279,20 @@ const TableWrapper = styled.div` border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); border-bottom-color: rgba(var(--center-channel-color-rgb), 0.08) !important; td { - padding-block-end: 4px; - padding-block-start: 4px; + padding-block-end: 0; + padding-block-start: 0; + + &:not(:first-child):not(:last-child) { + padding-inline-end: 0; + padding-inline-start: 0; + } &:last-child { padding-inline-end: 12px; } + &.pinned { + background: none; + } } } } @@ -295,50 +306,6 @@ const TableWrapper = styled.div` } `; -const Actions = ({field, deleteField}: {field: UserPropertyField} & Pick) => { - const {promptDelete} = useUserPropertyFieldDelete(); - const {formatMessage} = useIntl(); - - const handleDelete = () => { - if (isCreatePending(field)) { - // skip prompt when field is pending creation - deleteField(field.id); - } else { - promptDelete(field).then(() => deleteField(field.id)); - } - }; - - return ( - - {field.delete_at === 0 && ( - - - - )} - - ); -}; - -const TypeCellWrapper = styled.div<{$deleted?: boolean}>` - ${({$deleted}) => $deleted && css` - && { - color: #D24B4E; - text-decoration: line-through; - } - `}; - - vertical-align: middle; - display: inline-flex; - gap: 6px; - align-items: center; -`; - const ColHeaderLeft = styled.div` display: inline-block; `; @@ -353,7 +320,7 @@ const ActionsRoot = styled.div` text-align: right; `; -type EditableValueProps = { +type EditCellProps = { value: string; label?: string; testid?: string; @@ -366,7 +333,7 @@ type EditableValueProps = { maxLength?: number; borderless?: boolean; }; -const EditableValue = (props: EditableValueProps) => { +const EditCell = (props: EditCellProps) => { const [value, setValue] = useState(props.value); useEffect(() => { @@ -375,14 +342,13 @@ const EditableValue = (props: EditableValueProps) => { return ( <> - { diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_type_menu.scss b/webapp/channels/src/components/admin_console/system_properties/user_properties_type_menu.scss new file mode 100644 index 0000000000..388d7dbad2 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_type_menu.scss @@ -0,0 +1,33 @@ +.field-type-selector-menu-button { + width: 100%; + height: 40px; + justify-content: start; + border-color: transparent; + border-radius: 0; + box-shadow: none; + font-weight: normal; + &:hover, + &:focus { + border-color: transparent; + box-shadow: none; + } + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.04) + } + &:focus, + &[aria-expanded="true"] { + background: rgba(var(--button-bg-rgb), 0.08); + } + + &.deleted { + color: #D24B4E; + text-decoration: line-through; + } + + &.strong { + font-size: 14px; + font-style: normal; + font-weight: 600; + } +} diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_type_menu.test.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_type_menu.test.tsx new file mode 100644 index 0000000000..ef04280103 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_type_menu.test.tsx @@ -0,0 +1,118 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, screen} from '@testing-library/react'; +import React from 'react'; + +import type {UserPropertyField} from '@mattermost/types/properties'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import SelectType from './user_properties_type_menu'; + +describe('UserPropertyTypeMenu', () => { + const baseField: UserPropertyField = { + id: 'test-id', + name: 'Test Field', + type: 'text' as const, + group_id: 'custom_profile_attributes', + create_at: 1736541716295, + delete_at: 0, + update_at: 0, + attrs: { + sort_order: 0, + visibility: 'when_set' as const, + value_type: '', + }, + }; + + const updateField = jest.fn(); + + const renderComponent = (field: UserPropertyField = baseField) => { + return renderWithContext( + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with correct current type', () => { + renderComponent(); + + // The menu button should show the current type + expect(screen.getByText('Text')).toBeInTheDocument(); + }); + + it('disables menu button when field is marked for deletion', () => { + const deletedField = { + ...baseField, + delete_at: 123456789, + }; + + renderComponent(deletedField); + + // Find button and verify it's disabled + const menuButton = screen.getByTestId('fieldTypeSelectorMenuButton'); + expect(menuButton).toBeDisabled(); + }); + + it('changes field type when a new type is selected', () => { + renderComponent(); + + // Open the menu + fireEvent.click(screen.getByText('Text')); + + // Click to select Phone type + fireEvent.click(screen.getByText('Phone')); + + // Verify the field was updated with the new type + expect(updateField).toHaveBeenCalledWith({ + ...baseField, + type: 'text', + attrs: { + ...baseField.attrs, + value_type: 'phone', + }, + }); + }); + + it('filters options when searching', () => { + renderComponent(); + + // Open the menu + fireEvent.click(screen.getByText('Text')); + + // Type in the filter input + const filterInput = screen.getByRole('textbox', {name: 'Property type'}); + fireEvent.change(filterInput, {target: {value: 'multi'}}); + + // Should only see Multi-select now + expect(screen.getByText('Multi-select')).toBeInTheDocument(); + expect(screen.getAllByRole('menuitemradio')).toHaveLength(1); + }); + + it('shows check icon for current type', () => { + const selectField = { + ...baseField, + type: 'select' as const, + attrs: { + ...baseField.attrs, + value_type: '' as const, + }, + }; + + renderComponent(selectField); + + // Open the menu + fireEvent.click(screen.getByText('Select')); + + // All options should be visible, but Select should have a check + expect(screen.getByRole('menuitemradio', {name: 'Select'})).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('menuitemradio', {name: 'Text'})).toHaveAttribute('aria-checked', 'false'); + }); +}); diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_type_menu.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_type_menu.tsx new file mode 100644 index 0000000000..39124c85a0 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_type_menu.tsx @@ -0,0 +1,200 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import type {ComponentType} from 'react'; +import React, {useMemo, useState} from 'react'; +import type {MessageDescriptor} from 'react-intl'; +import {defineMessage, FormattedMessage, useIntl} from 'react-intl'; +import {css} from 'styled-components'; + +import {CheckIcon, ChevronDownCircleOutlineIcon, EmailOutlineIcon, FormatListBulletedIcon, LinkVariantIcon, MenuVariantIcon, PoundIcon} from '@mattermost/compass-icons/components'; +import type IconProps from '@mattermost/compass-icons/components/props'; +import type {FieldType, FieldValueType, UserPropertyField} from '@mattermost/types/properties'; +import type {IDMappedObjects} from '@mattermost/types/utilities'; + +import * as Menu from 'components/menu'; + +import './user_properties_type_menu.scss'; + +interface Props { + field: UserPropertyField; + updateField: (field: UserPropertyField) => void; +} + +const SelectType = (props: Props) => { + const {formatMessage} = useIntl(); + const [filter, setFilter] = useState(''); + + const onFilterChange = (e: React.ChangeEvent) => { + setFilter(e.target.value); + }; + + const handleTypeChange = (descriptor: TypeDescriptor) => { + props.updateField({...props.field, type: descriptor.fieldType, attrs: {...props.field.attrs, value_type: descriptor.valueType}}); + setFilter(''); + }; + + const options = useMemo(() => { + return Object.values(TYPE_DESCRIPTOR).filter((descriptor) => { + return formatMessage(descriptor.label).toLowerCase().includes(filter.toLowerCase()); + }); + }, [TYPE_DESCRIPTOR, filter]); + + const currentTypeDescriptor = useMemo(() => { + return getTypeDescriptor(props.field); + }, [props.field]); + const CurrentTypeIcon = currentTypeDescriptor.icon; + + return ( + + + + + ), + dataTestId: 'fieldTypeSelectorMenuButton', + disabled: props.field.delete_at !== 0, + }} + menu={{ + id: 'type-selector-menu', + 'aria-label': 'Select type', + className: 'select-type-mui-menu', + }} + > + {[ + , + ]} + {options.map((descriptor) => { + const {id, icon: Icon, label, disabled} = descriptor; + + if (disabled) { + return null; + } + + return ( + handleTypeChange(descriptor)} + labels={} + leadingElement={} + trailingElements={id === currentTypeDescriptor.id && ( + + )} + /> + ); + })} + + ); +}; + +export default SelectType; + +const getTypeDescriptor = (field: UserPropertyField): TypeDescriptor => { + for (const descriptor of Object.values(TYPE_DESCRIPTOR)) { + if (descriptor.fieldType === field.type && descriptor.valueType === field.attrs?.value_type) { + return descriptor; + } + } + + throw new Error('Invalid type'); +}; + +type TypeID = 'text' | 'email' | 'phone' | 'url' | 'select' | 'multiselect'; + +type TypeDescriptor = { + id: TypeID; + fieldType: FieldType; + valueType: FieldValueType; + icon: ComponentType; + label: MessageDescriptor; + disabled?: boolean; +}; + +const TYPE_DESCRIPTOR: IDMappedObjects = { + text: { + id: 'text', + fieldType: 'text', + valueType: '', + icon: MenuVariantIcon, + label: defineMessage({ + id: 'admin.system_properties.user_properties.table.select_type.text', + defaultMessage: 'Text', + }), + }, + email: { + id: 'email', + disabled: true, + fieldType: 'text', + valueType: 'email', + icon: EmailOutlineIcon, + label: defineMessage({ + id: 'admin.system_properties.user_properties.table.select_type.email', + defaultMessage: 'Email', + }), + }, + phone: { + id: 'phone', + fieldType: 'text', + valueType: 'phone', + icon: PoundIcon, + label: defineMessage({id: 'admin.system_properties.user_properties.table.select_type.phone', defaultMessage: 'Phone'}), + }, + url: { + id: 'url', + fieldType: 'text', + valueType: 'url', + icon: LinkVariantIcon, + label: defineMessage({ + id: 'admin.system_properties.user_properties.table.select_type.url', + defaultMessage: 'URL', + }), + }, + select: { + id: 'select', + fieldType: 'select', + valueType: '', + icon: ChevronDownCircleOutlineIcon, + label: defineMessage({ + id: 'admin.system_properties.user_properties.table.select_type.select', + defaultMessage: 'Select', + }), + }, + multiselect: { + id: 'multiselect', + fieldType: 'multiselect', + valueType: '', + icon: FormatListBulletedIcon, + label: defineMessage({ + id: 'admin.system_properties.user_properties.table.select_type.multi_select', + defaultMessage: 'Multi-select', + }), + }, +} as const; + +const menuInputContainerStyles = css` + padding: 0 12px; +`; 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 7c06bfebc1..2ef5a68852 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 @@ -48,11 +48,25 @@ describe('useUserPropertyFields', () => { const deleteField = jest.spyOn(Client4, 'deleteCustomProfileAttributeField'); const createField = jest.spyOn(Client4, 'createCustomProfileAttributeField'); - 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'}; + const baseField: UserPropertyField = { + id: 'test-id', + name: 'Test Field', + type: 'text' as const, + group_id: 'custom_profile_attributes', + create_at: 1736541716295, + delete_at: 0, + update_at: 0, + attrs: { + sort_order: 0, + visibility: 'when_set' as const, + value_type: '', + }, + }; + + const field0: UserPropertyField = {...baseField, id: 'test-id-0', name: 'test attribute 0', attrs: {...baseField.attrs, sort_order: 0}}; + const field1: UserPropertyField = {...baseField, id: 'test-id-1', name: 'test attribute 1', attrs: {...baseField.attrs, sort_order: 1}}; + const field2: UserPropertyField = {...baseField, id: 'test-id-2', name: 'test attribute 2', attrs: {...baseField.attrs, sort_order: 2}}; + const field3: UserPropertyField = {...baseField, id: 'test-id-3', name: 'test attribute 3', attrs: {...baseField.attrs, sort_order: 3}}; getFields.mockResolvedValue([field0, field1, field2, field3]); @@ -82,7 +96,7 @@ describe('useUserPropertyFields', () => { expect(read2.loading).toBe(false); expect(read2.error).toBe(undefined); expect(fields2.data).toEqual({[field0.id]: field0, [field1.id]: field1, [field2.id]: field2, [field3.id]: field3}); - expect(fields2.order).toEqual(['f0', 'f1', 'f2', 'f3']); + expect(fields2.order).toEqual([field0.id, field1.id, field2.id, field3.id]); }); it('should successfully handle edits', async () => { @@ -126,7 +140,7 @@ describe('useUserPropertyFields', () => { expect(pending.saving).toBe(false); }); - expect(patchField).toHaveBeenCalledWith(field1.id, {type: 'text', name: 'changed attribute value'}); + expect(patchField).toHaveBeenCalledWith(field1.id, {type: 'text', name: 'changed attribute value', attrs: {sort_order: 1, value_type: '', visibility: 'when_set'}}); const [fields4,, pendingIO4] = result.current; expect(pendingIO4.hasChanges).toBe(false); @@ -178,13 +192,13 @@ describe('useUserPropertyFields', () => { 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}}); + expect(patchField).toHaveBeenCalledWith(field1.id, {type: 'text', name: 'test attribute 1', attrs: {sort_order: 0, value_type: '', visibility: 'when_set'}}); + expect(patchField).toHaveBeenCalledWith(field0.id, {type: 'text', name: 'test attribute 0', attrs: {sort_order: 1, value_type: '', visibility: 'when_set'}}); const [fields4,, pendingIO4] = result.current; expect(pendingIO4.hasChanges).toBe(false); expect(pendingIO4.error).toBe(undefined); - expect(fields4.order).toEqual(['f1', 'f0', 'f2', 'f3']); + expect(fields4.order).toEqual([field1.id, field0.id, field2.id, field3.id]); }); it('should successfully handle deletes', async () => { @@ -254,14 +268,12 @@ describe('useUserPropertyFields', () => { act(() => { ops2.create(); - ops2.create(); }); rerender(); const [fields3, readIO3, pendingIO3] = result.current; - const [createdId0, createdId1] = [...fields3.order].splice(-2, 2); + const [createdId0] = [...fields3.order].splice(-1, 1); expect(fields3.data[createdId0].create_at).toBe(0); - expect(fields3.data[createdId1].create_at).toBe(0); await act(async () => { const data = await pendingIO3.commit(); @@ -277,13 +289,11 @@ describe('useUserPropertyFields', () => { expect(pendingIO4.saving).toBe(false); }); - expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text', attrs: {sort_order: 4}}); - expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text 2', attrs: {sort_order: 5}}); + expect(createField).toHaveBeenCalledWith({type: 'text', name: 'Text', attrs: {sort_order: 4, value_type: '', visibility: 'when_set'}}); const [fields4,,,] = result.current; expect(Object.values(fields4.data)).toEqual(expect.arrayContaining([ expect.objectContaining({name: 'Text'}), - expect.objectContaining({name: 'Text 2'}), ])); expect(fields4.order).toEqual(expect.arrayContaining(Object.keys(fields4.data))); 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 8667b22b85..1f562c5def 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 @@ -6,9 +6,9 @@ import isEmpty from 'lodash/isEmpty'; import {useMemo} from 'react'; import type {ClientError} from '@mattermost/client'; -import type {UserPropertyField} from '@mattermost/types/properties'; +import type {FieldValueType, FieldVisibility, UserPropertyField, UserPropertyFieldGroupID, UserPropertyFieldPatch} from '@mattermost/types/properties'; import {collectionAddItem, collectionFromArray, collectionRemoveItem, collectionReplaceItem, collectionToArray} from '@mattermost/types/utilities'; -import type {PartialExcept, IDMappedCollection, IDMappedObjects} from '@mattermost/types/utilities'; +import type {IDMappedCollection, IDMappedObjects} from '@mattermost/types/utilities'; import {Client4} from 'mattermost-redux/client'; import {insertWithoutDuplicates} from 'mattermost-redux/utils/array_utils'; @@ -56,6 +56,7 @@ export const useUserPropertyFields = () => { break; case item !== prevCollection.data[item.id]: ops.edit.push(item); + break; } return ops; @@ -85,8 +86,17 @@ export const useUserPropertyFields = () => { // update await Promise.all(process.edit.map(async (pendingItem) => { const {id, name, type, attrs} = pendingItem; + let patch = {name, type, attrs}; - return Client4.patchCustomProfileAttributeField(id, {name, type, attrs}). + // clear options if not select/multiselect + if (type !== 'select' && type !== 'multiselect') { + const attrs = {...patch.attrs}; + Reflect.deleteProperty(attrs, 'options'); + + patch = {...patch, attrs}; + } + + return Client4.patchCustomProfileAttributeField(id, patch). then((nextItem) => { // data:updated next.data[id] = nextItem; @@ -153,6 +163,13 @@ export const useUserPropertyFields = () => { } } + if (field.type === 'select' || field.type === 'multiselect') { + const options = field.attrs?.options; + if (!options?.length) { + acc[field.id] = {attrs: ValidationWarningOptionsRequired}; + } + } + return acc; }, {}); @@ -178,7 +195,7 @@ export const useUserPropertyFields = () => { 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', attrs: {sort_order: nextOrder}}); + const field = newPendingField({name, type: 'text', attrs: {sort_order: nextOrder, visibility: 'when_set', value_type: ''}}); return collectionAddItem(pending, field); }); }, @@ -195,7 +212,7 @@ export const useUserPropertyFields = () => { const itemNextOrder = nextOrder.indexOf(item.id); if (itemNextOrder !== itemCurrentOrder) { - changedItems.push({...item, attrs: {sort_order: itemNextOrder}}); + changedItems.push({...item, attrs: {...item.attrs, sort_order: itemNextOrder}}); } return changedItems; @@ -224,6 +241,7 @@ export const useUserPropertyFields = () => { export const ValidationWarningNameRequired = 'user_properties.validation.name_required'; export const ValidationWarningNameUnique = 'user_properties.validation.name_unique'; export const ValidationWarningNameTaken = 'user_properties.validation.name_taken'; +export const ValidationWarningOptionsRequired = 'user_properties.validation.options_required'; const getIncrementedName = (desiredName: string, collection: UserPropertyFields) => { const names = new Set(Object.values(collection.data).map(({name}) => name)); @@ -249,14 +267,21 @@ export const isDeletePending = `${PENDING}${generateId()}`; -export const newPendingField = (patch: PartialExcept): UserPropertyField => { +export const newPendingField = (patch: UserPropertyFieldPatch & Pick): UserPropertyField => { return { ...patch, type: 'text', - group_id: 'custom_profile_attributes', + group_id: 'custom_profile_attributes' satisfies UserPropertyFieldGroupID, id: newPendingId(), create_at: 0, delete_at: 0, update_at: 0, + attrs: { + visibility: 'when_set' satisfies FieldVisibility, + sort_order: 0, + value_type: '' satisfies FieldValueType, + ...patch.attrs, + }, + }; }; diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_values.scss b/webapp/channels/src/components/admin_console/system_properties/user_properties_values.scss new file mode 100644 index 0000000000..79dc81ae93 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_values.scss @@ -0,0 +1,32 @@ +.user-property-field-dotmenu-menu-button { + height: 40px; + justify-content: start; + border-color: transparent; + border-radius: 0; + box-shadow: none; + font-weight: normal; + &:hover, + &:focus { + border-color: transparent; + box-shadow: none; + } + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.04) + } + &:focus, + &[aria-expanded="true"] { + background: rgba(var(--button-bg-rgb), 0.08); + } + + &.deleted { + color: #D24B4E; + text-decoration: line-through; + } + + &.strong { + font-size: 14px; + font-style: normal; + font-weight: 600; + } +} diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_values.test.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_values.test.tsx new file mode 100644 index 0000000000..6f6e7347e2 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_values.test.tsx @@ -0,0 +1,146 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen, fireEvent} from '@testing-library/react'; +import React from 'react'; + +import type {UserPropertyField} from '@mattermost/types/properties'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import UserPropertyValues from './user_properties_values'; + +describe('UserPropertyValues', () => { + const baseField: UserPropertyField = { + id: 'test-id', + name: 'Test Field', + type: 'select', + group_id: 'custom_profile_attributes', + create_at: 1736541716295, + delete_at: 0, + update_at: 0, + attrs: { + sort_order: 0, + visibility: 'when_set', + value_type: '', + options: [ + {id: 'option1', name: 'Option 1'}, + {id: 'option2', name: 'Option 2'}, + ], + }, + }; + + const updateField = jest.fn(); + + const renderComponent = (field: UserPropertyField = baseField) => { + return renderWithContext( + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly for select/multiselect field types', () => { + renderComponent(); + + // Check that both options are displayed + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + }); + + it('renders dash for non-select field types', () => { + const textField = { + ...baseField, + type: 'text' as const, + }; + + renderComponent(textField); + + expect(screen.getByText('-')).toBeInTheDocument(); + }); + + it('adds a new option when typing and pressing Enter', async () => { + renderComponent(); + + const input = screen.getByRole('combobox'); + fireEvent.change(input, {target: {value: 'New Option'}}); + fireEvent.keyDown(input, {key: 'Enter'}); + + expect(updateField).toHaveBeenCalledWith({ + ...baseField, + attrs: { + ...baseField.attrs, + options: [ + ...baseField.attrs.options || [], + {id: '', name: 'New Option'}, + ], + }, + }); + }); + + it('adds a new option when typing and blurring', async () => { + renderComponent(); + + const input = screen.getByRole('combobox'); + fireEvent.change(input, {target: {value: 'New Option'}}); + fireEvent.blur(input); + + expect(updateField).toHaveBeenCalledWith({ + ...baseField, + attrs: { + ...baseField.attrs, + options: [ + ...baseField.attrs.options || [], + {id: '', name: 'New Option'}, + ], + }, + }); + }); + + it('removes an option when clicking the remove button', async () => { + renderComponent(); + + // Find and click the first remove button (x) + const removeButtons = screen.getAllByRole('button'); + fireEvent.click(removeButtons[0]); + + expect(updateField).toHaveBeenCalledWith({ + ...baseField, + attrs: { + ...baseField.attrs, + options: [{id: 'option2', name: 'Option 2'}], + }, + }); + }); + + it('shows validation error when trying to add a duplicate option', async () => { + renderComponent(); + + const input = screen.getByRole('combobox'); + fireEvent.change(input, {target: {value: 'Option 1'}}); // This already exists + + // Error message should appear + expect(screen.getByText('Values must be unique.')).toBeInTheDocument(); + + // Pressing Enter shouldn't add the duplicate + fireEvent.keyDown(input, {key: 'Enter'}); + expect(updateField).not.toHaveBeenCalled(); + }); + + it('is disabled when the field is marked for deletion', () => { + const deletedField = { + ...baseField, + delete_at: 123456789, + }; + + renderComponent(deletedField); + + const option = screen.getByText('Option 1'); + expect(option.closest('div[aria-disabled]')).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_values.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_values.tsx new file mode 100644 index 0000000000..1eae9eb780 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_values.tsx @@ -0,0 +1,180 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {FocusEventHandler, KeyboardEventHandler} from 'react'; +import React, {useMemo} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import type {GroupBase} from 'react-select'; +import {components} from 'react-select'; +import type {CreatableProps} from 'react-select/creatable'; +import CreatableSelect from 'react-select/creatable'; + +import type {PropertyFieldOption, UserPropertyField} from '@mattermost/types/properties'; + +import Constants from 'utils/constants'; + +import {DangerText} from './controls'; + +// import './user_properties_dot_menu.scss'; + +type Props = { + field: UserPropertyField; + updateField: (field: UserPropertyField) => void; +} + +type Option = {label: string; id: string; value: string}; +type SelectProps = CreatableProps>; + +const UserPropertyValues = ({ + field, + updateField, +}: Props) => { + const {formatMessage} = useIntl(); + + const [query, setQuery] = React.useState(''); + const isQueryValid = useMemo(() => !checkForDuplicates(field.attrs.options, query.trim()), [field?.attrs?.options, query]); + + const addOption = (name: string) => { + const option: PropertyFieldOption = { + id: '', + name: name.trim(), + }; + + updateField({...field, attrs: {...field.attrs, options: [...field.attrs.options ?? [], option]}}); + }; + + const setFieldOptions = (options: PropertyFieldOption[]) => { + updateField({...field, attrs: {...field.attrs, options}}); + }; + + const processQuery = (query: string) => { + addOption(query); + setQuery(''); + }; + + const handleKeyDown: KeyboardEventHandler = (event) => { + if (!query || !isQueryValid) { + return; + } + + switch (event.key) { + case 'Enter': + case 'Tab': + processQuery(query); + event.preventDefault(); + } + }; + + const handleOnBlur: FocusEventHandler = (event) => { + if (!query || !isQueryValid) { + return; + } + + processQuery(query); + event.preventDefault(); + }; + + if (field.type !== 'multiselect' && field.type !== 'select') { + return ( + <> + {'-'} + + ); + } + + return ( + <> + > + components={customComponents} + inputValue={query} + isClearable={true} + isMulti={true} + menuIsOpen={false} + isDisabled={field.delete_at !== 0} + onChange={(newValues) => { + setFieldOptions(newValues.map(({id, value}) => ({id, name: value}))); + }} + onInputChange={(newValue) => setQuery(newValue)} + onKeyDown={handleKeyDown} + onBlur={handleOnBlur} + placeholder={formatMessage({id: 'admin.system_properties.user_properties.table.values.placeholder', defaultMessage: 'Add values… (required)'})} + value={field.attrs.options?.map((option) => ({label: option.name, value: option.name, id: option.id}))} + menuPortalTarget={document.body} + styles={styles} + /> + {!isQueryValid && ( + + )} + + ); +}; + +const checkForDuplicates = (options: PropertyFieldOption[] | undefined, newOptionName: string) => { + return options?.some((option) => option.name === newOptionName); +}; + +const customComponents: SelectProps['components'] = { + DropdownIndicator: undefined, + ClearIndicator: undefined, + IndicatorsContainer: () => null, + Input: (props) => { + return ( + + ); + }, +}; + +const styles: SelectProps['styles'] = { + multiValue: (base) => ({ + ...base, + borderRadius: '12px', + paddingLeft: '6px', + paddingTop: '1px', + paddingBottom: '1px', + }), + multiValueLabel: (base) => ({ + ...base, + color: 'var(--center-channel-color)', + fontFamily: 'Open Sans', + fontSize: '12px', + fontStyle: 'normal', + fontWeight: 600, + lineHeight: '16px', + }), + multiValueRemove: (base) => ({ + ...base, + cursor: 'pointer', + color: 'var(--center-channel-color)', + borderRadius: '0 12px 12px 0', + '&:hover': { + backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.08)', + color: 'var(--center-channel-color)', + }, + }), + control: (base, props) => ({ + ...base, + minHeight: '40px', + overflowY: 'auto', + border: 'none', + borderRadius: '0', + ...props.isFocused && { + border: 'none', + boxShadow: 'none', + background: 'rgba(var(--button-bg-rgb), 0.08)', + }, + '&:hover': { + background: 'rgba(var(--button-bg-rgb), 0.08)', + cursor: 'text', + }, + }), +}; + +export default UserPropertyValues; + diff --git a/webapp/channels/src/components/menu/menu_item_input.tsx b/webapp/channels/src/components/menu/menu_item_input.tsx index 998b312d33..b2c0740f6c 100644 --- a/webapp/channels/src/components/menu/menu_item_input.tsx +++ b/webapp/channels/src/components/menu/menu_item_input.tsx @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import React from 'react'; +import type {css} from 'styled-components'; import styled from 'styled-components'; import type {InputProps} from 'components/widgets/inputs/input/input'; @@ -9,11 +10,13 @@ import Input from 'components/widgets/inputs/input/input'; export interface Props extends InputProps { type: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url'; + customStyles?: ReturnType; } export function MenuItemInput(props: Props) { const { type, + customStyles, onChange, ...otherProps } = props; @@ -30,7 +33,7 @@ export function MenuItemInput(props: Props) { }; return ( - + }>` padding: 10px; + ${({$customStyles}) => $customStyles}; `; diff --git a/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx b/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx index d48e03ee38..1d7e07fa65 100644 --- a/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx +++ b/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx @@ -71,6 +71,11 @@ describe('components/user_settings/general/UserSettingsGeneral', () => { create_at: 0, update_at: 0, delete_at: 0, + attrs: { + sort_order: 0, + visibility: 'when_set', + value_type: '', + }, }; let store: ReturnType; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 517720c990..8c2746a5ad 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -2578,16 +2578,29 @@ "admin.system_properties.details.saving_changes": "Saving configuration…", "admin.system_properties.details.saving_changes_error": "There was an error while saving the configuration", "admin.system_properties.user_properties.add_property": "Add property", + "admin.system_properties.user_properties.dotmenu.delete.label": "Delete property", + "admin.system_properties.user_properties.dotmenu.visibility.always.label": "Always show", + "admin.system_properties.user_properties.dotmenu.visibility.hidden.label": "Always hide", + "admin.system_properties.user_properties.dotmenu.visibility.label": "Visibility", + "admin.system_properties.user_properties.dotmenu.visibility.when_set.label": "Hide when empty", "admin.system_properties.user_properties.subtitle": "Customize the properties to show in user profiles", "admin.system_properties.user_properties.table.actions": "Actions", - "admin.system_properties.user_properties.table.actions.delete": "Delete", + "admin.system_properties.user_properties.table.filter_type": "Property type", "admin.system_properties.user_properties.table.property": "Property", "admin.system_properties.user_properties.table.property_name.input.name": "Property Name", + "admin.system_properties.user_properties.table.select_type.email": "Email", + "admin.system_properties.user_properties.table.select_type.multi_select": "Multi-select", + "admin.system_properties.user_properties.table.select_type.phone": "Phone", + "admin.system_properties.user_properties.table.select_type.select": "Select", + "admin.system_properties.user_properties.table.select_type.text": "Text", + "admin.system_properties.user_properties.table.select_type.url": "URL", "admin.system_properties.user_properties.table.type": "Type", - "admin.system_properties.user_properties.table.type.text": "Text", "admin.system_properties.user_properties.table.validation.name_required": "Please enter a property name.", "admin.system_properties.user_properties.table.validation.name_taken": "Property name already taken.", "admin.system_properties.user_properties.table.validation.name_unique": "Property names must be unique.", + "admin.system_properties.user_properties.table.validation.values_unique": "Values must be unique.", + "admin.system_properties.user_properties.table.values": "Values", + "admin.system_properties.user_properties.table.values.placeholder": "Add values… (required)", "admin.system_properties.user_properties.title": "User Properties", "admin.system_roles_feature_discovery.copy": "Assign customizable admin roles to give designated users read and/or write access to select sections of System Console.", "admin.system_roles_feature_discovery.title": "Provide controlled access to the System Console with Mattermost Enterprise", diff --git a/webapp/platform/types/src/properties.ts b/webapp/platform/types/src/properties.ts index 5b57751bef..ebd1972e9d 100644 --- a/webapp/platform/types/src/properties.ts +++ b/webapp/platform/types/src/properties.ts @@ -1,11 +1,20 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +export type FieldType = ( + 'text' | + 'select' | + 'multiselect' | + 'date' | + 'user' | + 'multiuser' +); + export type PropertyField = { id: string; group_id: string; name: string; - type: string; + type: FieldType; attrs?: {[key: string]: unknown}; target_id?: string; target_type?: string; @@ -25,12 +34,31 @@ export type PropertyValue = { delete_at: number; } -export type UserPropertyFieldType = 'text'; export type UserPropertyFieldGroupID = 'custom_profile_attributes'; -export type UserPropertyField = PropertyField & { - type: UserPropertyFieldType; - group_id: UserPropertyFieldGroupID; - attrs?: {sort_order?: number}; +export type FieldVisibility = 'always' | 'hidden' | 'when_set'; +export type FieldValueType = + 'email' | + 'url' | + 'phone' | + ''; + +export type PropertyFieldOption = { + id: string; + name: string; + color?: string; } -export type UserPropertyFieldPatch = Partial>; + +export type UserPropertyField = PropertyField & { + group_id: UserPropertyFieldGroupID; + attrs: { + sort_order: number; + visibility: FieldVisibility; + value_type: FieldValueType; + options?: PropertyFieldOption[]; + ldap?: string; + saml?: string; + }; +}; + +export type UserPropertyFieldPatch = Partial>;