diff --git a/webapp/channels/src/components/admin_console/blockable_link/blockable_link.test.tsx b/webapp/channels/src/components/admin_console/blockable_link/blockable_link.test.tsx new file mode 100644 index 0000000000..b426704f2b --- /dev/null +++ b/webapp/channels/src/components/admin_console/blockable_link/blockable_link.test.tsx @@ -0,0 +1,99 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, render, screen} from '@testing-library/react'; +import React from 'react'; +import {MemoryRouter} from 'react-router-dom'; + +import BlockableLink from './blockable_link'; + +jest.mock('utils/browser_history', () => ({ + getHistory: jest.fn().mockReturnValue({ + push: jest.fn(), + }), +})); + +describe('components/admin_console/blockable_link/BlockableLink', () => { + const defaultProps = { + to: '/admin_console/test', + blocked: false, + actions: { + deferNavigation: jest.fn(), + }, + children: 'Link Text', + }; + + test('should render properly', () => { + render( + + + , + ); + + expect(screen.getByText('Link Text')).toBeInTheDocument(); + expect(screen.getByRole('link')).toHaveAttribute('href', '/admin_console/test'); + }); + + test('should navigate directly when not blocked', () => { + render( + + + , + ); + + fireEvent.click(screen.getByText('Link Text')); + expect(defaultProps.actions.deferNavigation).not.toHaveBeenCalled(); + }); + + test('should defer navigation when blocked', () => { + const blockedProps = { + ...defaultProps, + blocked: true, + }; + + render( + + + , + ); + + fireEvent.click(screen.getByText('Link Text')); + expect(blockedProps.actions.deferNavigation).toHaveBeenCalled(); + }); + + test('should call custom onClick handler if provided', () => { + const onClickProps = { + ...defaultProps, + onClick: jest.fn(), + }; + + render( + + + , + ); + + fireEvent.click(screen.getByText('Link Text')); + expect(onClickProps.onClick).toHaveBeenCalled(); + }); + + test('should apply additional props correctly', () => { + const customProps = { + ...defaultProps, + className: 'custom-class', + id: 'custom-id', + 'data-testid': 'custom-test-id', + }; + + render( + + + , + ); + + const link = screen.getByRole('link'); + expect(link).toHaveClass('custom-class'); + expect(link).toHaveAttribute('id', 'custom-id'); + expect(link).toHaveAttribute('data-testid', 'custom-test-id'); + }); +}); 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 index ddd90366b2..bc1464f0a6 100644 --- 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 @@ -3,6 +3,7 @@ import {fireEvent, screen, waitFor} from '@testing-library/react'; import React from 'react'; +import type {ComponentProps} from 'react'; import type {UserPropertyField} from '@mattermost/types/properties'; @@ -30,19 +31,23 @@ describe('UserPropertyDotMenu', () => { const updateField = jest.fn(); const deleteField = jest.fn(); + const createField = jest.fn(); beforeEach(() => { jest.clearAllMocks(); }); - const renderComponent = (field: UserPropertyField = baseField) => { + const renderComponent = (field: UserPropertyField = baseField, dotMenuProps?: Partial>) => { return renderWithContext( (
@@ -105,6 +110,51 @@ describe('UserPropertyDotMenu', () => { }); }); + it('displays LDAP and SAML link menu options', async () => { + renderComponent(); + + // Open the menu + const menuButton = screen.getByTestId(`user-property-field_dotmenu-${baseField.id}`); + fireEvent.click(menuButton); + + // Verify both link options are shown + expect(screen.getByText('Link property to AD/LDAP')).toBeInTheDocument(); + expect(screen.getByText('Link property to SAML')).toBeInTheDocument(); + + // TODO mock history and verify the link actions + }); + + it('handles field duplication', async () => { + renderComponent(); + + // Open the menu + const menuButton = screen.getByTestId(`user-property-field_dotmenu-${baseField.id}`); + fireEvent.click(menuButton); + + // Click the duplicate option + fireEvent.click(screen.getByText(/Duplicate property/)); + + // Wait for createField to be called + await waitFor(() => { + // Verify createField was called with the correct parameters + expect(createField).toHaveBeenCalledWith(expect.objectContaining({ + id: baseField.id, + name: 'Test Field (copy)', + })); + }); + }); + + it('hides field duplication when at field limit', async () => { + renderComponent(undefined, {canCreate: false}); + + // Open the menu + const menuButton = screen.getByTestId(`user-property-field_dotmenu-${baseField.id}`); + fireEvent.click(menuButton); + + // Verify duplicate option is not shown + expect(screen.queryByText(/Duplicate property/)).not.toBeInTheDocument(); + }); + it('handles field deletion with confirmation when field exists in DB', async () => { renderComponent(); 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 index 098cadf8b2..19ea4aa34b 100644 --- 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 @@ -2,9 +2,9 @@ // See LICENSE.txt for license information. import React from 'react'; -import {FormattedMessage} from 'react-intl'; +import {FormattedMessage, useIntl} from 'react-intl'; -import {CheckIcon, ChevronRightIcon, DotsHorizontalIcon, EyeOutlineIcon, TrashCanOutlineIcon} from '@mattermost/compass-icons/components'; +import {CheckIcon, ChevronRightIcon, DotsHorizontalIcon, EyeOutlineIcon, SyncIcon, TrashCanOutlineIcon, ContentCopyIcon} from '@mattermost/compass-icons/components'; import type {FieldVisibility, UserPropertyField} from '@mattermost/types/properties'; import * as Menu from 'components/menu'; @@ -12,9 +12,10 @@ 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; + canCreate: boolean; + createField: (field: UserPropertyField) => void; updateField: (field: UserPropertyField) => void; deleteField: (id: string) => void; } @@ -23,11 +24,23 @@ const menuId = 'user-property-field_dotmenu'; const DotMenu = ({ field, + canCreate, + createField, updateField, deleteField, }: Props) => { + const {formatMessage} = useIntl(); const {promptDelete} = useUserPropertyFieldDelete(); + const handleDuplicate = () => { + const name = formatMessage({ + id: 'admin.system_properties.user_properties.dotmenu.duplicate.name_copy', + defaultMessage: '{fieldName} (copy)', + }, {fieldName: field.name}); + + createField({...field, attrs: {...field.attrs}, name}); + }; + const handleDelete = () => { if (isCreatePending(field)) { // skip prompt when field is pending creation @@ -161,18 +174,53 @@ const DotMenu = ({ )} /> + } + labels={( + + )} + /> + } + labels={( + + )} + /> + {canCreate && ( + } + labels={( + + )} + /> + )} } labels={( )} - leadingElement={} /> ); 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 index 024992e104..1109c72bd9 100644 --- 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 @@ -53,6 +53,7 @@ describe('UserPropertiesTable', () => { }, ]; + const createField = jest.fn(); const updateField = jest.fn(); const deleteField = jest.fn(); const reorderField = jest.fn(); @@ -67,6 +68,8 @@ describe('UserPropertiesTable', () => { return renderWithContext( { renderWithContext( void; updateField: (field: UserPropertyField) => void; deleteField: (id: string) => void; reorderField: (field: UserPropertyField, nextOrder: number) => void; @@ -39,6 +36,12 @@ export const useUserPropertiesTable = (): SectionHook => { const [userPropertyFields, readIO, pendingIO, itemOps] = useUserPropertyFields(); const nonDeletedCount = Object.values(userPropertyFields.data).filter((f) => f.delete_at === 0).length; + const canCreate = nonDeletedCount < Constants.MAX_CUSTOM_ATTRIBUTES; + + const create = () => { + itemOps.create(); + }; + const save = async () => { const newData = await pendingIO.commit(); @@ -54,12 +57,14 @@ export const useUserPropertiesTable = (): SectionHook => { <> - {nonDeletedCount < Constants.MAX_CUSTOM_ATTRIBUTES && ( - + {canCreate && ( + { }; }; -export function UserPropertiesTable({data: collection, updateField, deleteField, reorderField}: Props & FieldActions) { +type Props = { + data: UserPropertyFields; + canCreate: boolean; +} + +export function UserPropertiesTable({ + data: collection, + canCreate, + createField, + updateField, + deleteField, + reorderField, +}: Props & FieldActions) { const {formatMessage} = useIntl(); const data = collectionToArray(collection); const col = createColumnHelper(); @@ -216,6 +233,8 @@ export function UserPropertiesTable({data: collection, updateField, deleteField, @@ -225,7 +244,7 @@ export function UserPropertiesTable({data: collection, updateField, deleteField, enableSorting: false, }), ]; - }, [updateField, deleteField, collection.warnings]); + }, [createField, updateField, deleteField, collection.warnings, canCreate]); const table = useReactTable({ data, 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 index ef04280103..a25c67173d 100644 --- 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 @@ -96,6 +96,34 @@ describe('UserPropertyTypeMenu', () => { expect(screen.getAllByRole('menuitemradio')).toHaveLength(1); }); + it('disables non-supported options when ldap-linked', () => { + renderComponent({...baseField, attrs: {...baseField.attrs, ldap: 'ldapPropName'}}); + + // Open the menu + fireEvent.click(screen.getByText('Text')); + + // Non-text should be disabled + expect(screen.getByRole('menuitemradio', {name: 'Phone'})).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('menuitemradio', {name: 'URL'})).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('menuitemradio', {name: 'Select'})).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('menuitemradio', {name: 'Multi-select'})).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('menuitemradio', {name: 'Select'})).toHaveAttribute('aria-disabled', 'true'); + }); + + it('disables non-supported options when saml-linked', () => { + renderComponent({...baseField, attrs: {...baseField.attrs, saml: 'samlPropName'}}); + + // Open the menu + fireEvent.click(screen.getByText('Text')); + + // Non-text should be disabled + expect(screen.getByRole('menuitemradio', {name: 'Phone'})).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('menuitemradio', {name: 'URL'})).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('menuitemradio', {name: 'Select'})).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('menuitemradio', {name: 'Multi-select'})).toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('menuitemradio', {name: 'Select'})).toHaveAttribute('aria-disabled', 'true'); + }); + it('shows check icon for current type', () => { const selectField = { ...baseField, 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 index 39124c85a0..b6d56484cc 100644 --- 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 @@ -70,7 +70,7 @@ const SelectType = (props: Props) => { }} > {[ - { />, ]} {options.map((descriptor) => { - const {id, icon: Icon, label, disabled} = descriptor; + const {id, icon: Icon, label, hidden, canSync} = descriptor; - if (disabled) { + if (hidden) { return null; } + const isSyncing = props.field.attrs.ldap || props.field.attrs.saml; + const disabled = Boolean(isSyncing && !canSync); + return ( handleTypeChange(descriptor)} labels={} @@ -131,7 +135,9 @@ type TypeDescriptor = { valueType: FieldValueType; icon: ComponentType; label: MessageDescriptor; - disabled?: boolean; + + hidden?: boolean; + canSync?: boolean; // ldap/saml }; const TYPE_DESCRIPTOR: IDMappedObjects = { @@ -144,10 +150,11 @@ const TYPE_DESCRIPTOR: IDMappedObjects = { id: 'admin.system_properties.user_properties.table.select_type.text', defaultMessage: 'Text', }), + canSync: true, }, email: { id: 'email', - disabled: true, + hidden: true, fieldType: 'text', valueType: 'email', icon: EmailOutlineIcon, 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 1f562c5def..2d1a150593 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 @@ -191,11 +191,22 @@ export const useUserPropertyFields = () => { return collectionReplaceItem(pending, field); }); }, - create: () => { + create: (patch?) => { 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, visibility: 'when_set', value_type: ''}}); + + const field = newPendingField({ + type: 'text', + ...patch, + name: getIncrementedName(patch?.name ?? 'Text', pending), + attrs: { + visibility: 'when_set', + value_type: '', + ...patch?.attrs, + sort_order: nextOrder, + }, + }); + return collectionAddItem(pending, field); }); }, @@ -268,9 +279,20 @@ export const isDeletePending = `${PENDING}${generateId()}`; export const newPendingField = (patch: UserPropertyFieldPatch & Pick): UserPropertyField => { + const attrs = {...patch.attrs}; + + if (attrs.options) { + // clear option ids + attrs.options = patch.attrs?.options?.map((option) => ({...option, id: ''})); + } + + // clear ldap/saml links + Reflect.deleteProperty(attrs, 'ldap'); + Reflect.deleteProperty(attrs, 'saml'); + return { - ...patch, type: 'text', + ...patch, group_id: 'custom_profile_attributes' satisfies UserPropertyFieldGroupID, id: newPendingId(), create_at: 0, @@ -280,8 +302,7 @@ export const newPendingField = (patch: UserPropertyFieldPatch & Pick { const option = screen.getByText('Option 1'); expect(option.closest('div[aria-disabled]')).toBeInTheDocument(); }); + + it('shows LDAP sync information when field has LDAP attribute', () => { + const ldapField = { + ...baseField, + attrs: { + ...baseField.attrs, + ldap: 'ldapAttribute', + }, + }; + + renderComponent(ldapField); + + // Check that the sync info is displayed + expect(screen.getByText(/Synced with:/)).toBeInTheDocument(); + const ldapLink = screen.getByText('AD/LDAP: ldapAttribute'); + expect(ldapLink).toBeInTheDocument(); + + // Check that the link points to the correct location + const linkElement = screen.getByTestId(`user-property-field-values__ldap-${ldapField.name}`); + expect(linkElement).toBeInTheDocument(); + expect(linkElement).toHaveAttribute('href', `/admin_console/authentication/ldap#custom_profile_attribute-${baseField.name}`); + }); + + it('shows SAML sync information when field has SAML attribute', () => { + const samlField = { + ...baseField, + attrs: { + ...baseField.attrs, + saml: 'samlAttribute', + }, + }; + + renderComponent(samlField); + + // Check that the sync info is displayed + expect(screen.getByText(/Synced with:/)).toBeInTheDocument(); + const samlLink = screen.getByText('SAML: samlAttribute'); + expect(samlLink).toBeInTheDocument(); + + // Check that the link points to the correct location + const linkElement = screen.getByTestId(`user-property-field-values__saml-${samlField.name}`); + expect(linkElement).toBeInTheDocument(); + expect(linkElement).toHaveAttribute('href', `/admin_console/authentication/saml#custom_profile_attribute-${baseField.name}`); + }); + + it('shows both LDAP and SAML sync information when field has both attributes', () => { + const syncedField = { + ...baseField, + attrs: { + ...baseField.attrs, + ldap: 'ldapAttribute', + saml: 'samlAttribute', + }, + }; + + renderComponent(syncedField); + + // Check that the sync info is displayed + expect(screen.getByText(/Synced with:/)).toBeInTheDocument(); + + const ldapLink = screen.getByText('AD/LDAP: ldapAttribute'); + expect(ldapLink).toBeInTheDocument(); + + const samlLink = screen.getByText('SAML: samlAttribute'); + expect(samlLink).toBeInTheDocument(); + + // Check that both links point to the correct locations + const ldapLinkElement = screen.getByTestId(`user-property-field-values__ldap-${baseField.name}`); + expect(ldapLinkElement).toBeInTheDocument(); + + const samlLinkElement = screen.getByTestId(`user-property-field-values__ldap-${baseField.name}`); + expect(samlLinkElement).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 index 1eae9eb780..24bfdebe86 100644 --- 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 @@ -3,19 +3,21 @@ import type {FocusEventHandler, KeyboardEventHandler} from 'react'; import React, {useMemo} from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; +import {FormattedList, 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 {SyncIcon} from '@mattermost/compass-icons/components'; import type {PropertyFieldOption, UserPropertyField} from '@mattermost/types/properties'; import Constants from 'utils/constants'; import {DangerText} from './controls'; -// import './user_properties_dot_menu.scss'; +import './user_properties_values.scss'; +import BlockableLink from '../blockable_link'; type Props = { field: UserPropertyField; @@ -74,6 +76,52 @@ const UserPropertyValues = ({ event.preventDefault(); }; + if (field.attrs.ldap || field.attrs.saml) { + const syncedProperties = [ + + field.attrs.ldap && ( + + + + ), + field.attrs.saml && ( + + + + ), + + ].filter(Boolean); + + return ( + + + }} + /> + + ); + } + if (field.type !== 'multiselect' && field.type !== 'select') { return ( <> @@ -138,6 +186,7 @@ const styles: SelectProps['styles'] = { paddingLeft: '6px', paddingTop: '1px', paddingBottom: '1px', + backgroundColor: 'rgba(var(--center-channel-color-rgb), 0.08)', }), multiValueLabel: (base) => ({ ...base, diff --git a/webapp/channels/src/components/menu/index.ts b/webapp/channels/src/components/menu/index.ts index 4b37e03c1f..9cacc04738 100644 --- a/webapp/channels/src/components/menu/index.ts +++ b/webapp/channels/src/components/menu/index.ts @@ -6,7 +6,8 @@ import './menu.scss'; export {Menu as Container} from './menu'; export {SubMenu} from './sub_menu'; export {MenuItem as Item} from './menu_item'; -export {MenuItemInput as Input} from './menu_item_input'; +export {MenuItemInput as InputItem} from './menu_item_input'; +export {MenuItemLink as LinkItem} from './menu_item_link'; export {MenuTitle as Title} from './menu_title'; export type {FirstMenuItemProps} from './menu_item'; export {MenuItemSeparator as Separator} from './menu_item_separator'; diff --git a/webapp/channels/src/components/menu/menu_item_link.tsx b/webapp/channels/src/components/menu/menu_item_link.tsx new file mode 100644 index 0000000000..8441b79148 --- /dev/null +++ b/webapp/channels/src/components/menu/menu_item_link.tsx @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useSelector, useDispatch} from 'react-redux'; +import {useHistory, useLocation} from 'react-router-dom'; + +import {deferNavigation} from 'actions/admin_actions'; + +import type {GlobalState} from 'types/store'; + +import type {Props as MenuItemProps} from './menu_item'; +import {MenuItem} from './menu_item'; + +import {getNavigationBlocked} from '../../selectors/views/admin'; + +type Props = MenuItemProps & { + to: string; + onClick?: MenuItemProps['onClick']; +} + +export function MenuItemLink({ + to, + onClick, + ...otherProps +}: Props) { + const dispatch = useDispatch(); + const history = useHistory(); + const {pathname} = useLocation(); + + const blocked = useSelector((state: GlobalState) => pathname.startsWith('/admin_console') && getNavigationBlocked(state)); + + const handleClick: MenuItemProps['onClick'] = useCallback((e) => { + onClick?.(e); + + if (blocked) { + e.preventDefault(); + dispatch(deferNavigation(() => { + history.push(to); + })); + } else { + history.push(to); + } + }, [blocked, onClick, deferNavigation, history.push, to]); + + return ( + + ); +} diff --git a/webapp/channels/src/components/new_search/select_team.tsx b/webapp/channels/src/components/new_search/select_team.tsx index 11fcf5027d..ba29b19bc5 100644 --- a/webapp/channels/src/components/new_search/select_team.tsx +++ b/webapp/channels/src/components/new_search/select_team.tsx @@ -101,7 +101,7 @@ const SelectTeam = (props: Props) => { // MUI Menu doesn't support fragments, and the recommended alternative is to use an array. const renderFilterArea = () => { const elements = [ -