From 79561c44c289290adecf82f438a935e08f7f5126 Mon Sep 17 00:00:00 2001 From: Caleb Roseland Date: Tue, 22 Apr 2025 05:55:05 -0500 Subject: [PATCH] MM-63276, MM-62707: CPA LDAP/SAML links and Duplicate field (#30772) * Squashed commit of the following: commit 42ef1ff8c3ff881b0f21cb4de23a5964f0bb106b Merge: 4940da4326 c049748b88 Author: Mattermost Build Date: Fri Mar 14 21:15:22 2025 +0200 Merge branch 'master' into MM-62695 commit 4940da4326ff787dd43fc486f06be415257181ae Author: Julien Tant Date: Fri Mar 14 12:14:45 2025 -0700 manage error the same way property field and value do commit cd9ec590264ca3751a55fc0926318cbb6f46d471 Author: Julien Tant Date: Fri Mar 14 12:05:02 2025 -0700 sanitize and validate commit 2672e90b06331ff2e31a7807737cc1feff1c1beb Author: Julien Tant Date: Fri Mar 14 12:02:20 2025 -0700 use Len test method commit d101950d655c7d6fd7a668a15554cc52a1d42667 Author: Julien Tant Date: Fri Mar 14 11:59:12 2025 -0700 rearrange consts commit ed1b8f66fb08f7be38615ced74282380d5d680b7 Author: Julien Tant Date: Fri Mar 14 11:58:56 2025 -0700 rename CPASortOrder method commit d3bc303fa3c9694d2f6d1531186f4fbe69efb956 Author: Julien Tant Date: Fri Mar 14 11:54:24 2025 -0700 rename CustomProfileAttributes in method to CPA commit b2323d44a6c3f31ea7c798f8a88c62878d5d2cdf Author: Julien Tant Date: Fri Mar 14 11:54:02 2025 -0700 Add SAML and LDAP attr commit d411ae9da5a078cfbac60c5662bee27622ff31e5 Author: Julien Tant Date: Wed Mar 12 11:58:21 2025 -0700 i18n commit 27bc74c71462ae08d496104c908454ebc4f2def3 Author: Julien Tant Date: Wed Mar 12 11:11:23 2025 -0700 fix TestDeleteCPAField test commit 0d77071225d8575ace89cbe38c6c06fa94d7696b Author: Julien Tant Date: Wed Mar 12 10:28:49 2025 -0700 err->appErr commit fe87a68caa49c7264c20c32cd4f598ffd18d503f Author: Julien Tant Date: Wed Mar 12 10:22:13 2025 -0700 i18n commit 3b3ddf978fce66d9506416a10c03b21d6a12e9d0 Author: Julien Tant Date: Wed Mar 12 10:21:12 2025 -0700 tests commit 2f898bd53b832dea60dcdb960a71cd47709cddf8 Author: Julien Tant Date: Tue Mar 11 15:09:12 2025 -0700 add validation and tests commit a8c20841af6e82a6bf4f5dde6324b5e56c4d754e Author: Julien Tant Date: Tue Mar 11 13:45:53 2025 -0700 code style commit 5bb3868b2ce93b9eb6e9d0836c597fa1176fbbbd Merge: a4180d5d8f 7c25de2cff Author: Mattermost Build Date: Tue Mar 11 22:24:06 2025 +0200 Merge branch 'master' into MM-62695 commit a4180d5d8ff5e23a7a0a73b08806d37289e076ce Author: Julien Tant Date: Tue Feb 25 11:53:54 2025 -0700 use CPAField commit 988177024ceebe73750ce48c40ea0a9ca6db75d6 Author: Julien Tant (aider) Date: Tue Feb 25 10:52:47 2025 -0700 refactor: Move validateCustomProfileAttributesField to Validate method on CPAField struct commit 783e64472c7ef3c1e33ce9a94b0716e96a72b105 Author: Julien Tant Date: Tue Feb 25 10:52:45 2025 -0700 refactor: Improve custom profile attributes field handling and validation commit aee06af59c748e74e5dc7718c1529527ac8c052a Author: Julien Tant (aider) Date: Tue Feb 25 10:50:29 2025 -0700 test: Add test case for CPA field with empty attributes commit 7ab4455f9feb88826fcf6abb3b93a8a7bb53ed3e Author: Julien Tant Date: Tue Feb 25 10:50:28 2025 -0700 refactor: Cleanup whitespace and remove empty Attrs in custom profile attributes test commit dbdb47d75818d58739c3962d8624388214382611 Author: Julien Tant (aider) Date: Tue Feb 25 10:48:08 2025 -0700 test: Add test case for property field with empty attributes commit 43872e06933b40f45caadbca7708eab283cebd8d Author: Julien Tant (aider) Date: Tue Feb 25 10:44:39 2025 -0700 test: Add tests for NewCPAFieldFromPropertyField and CPAFieldToPropertyField commit 335f6b5c8901b6ecdc1e9f031a81695ffc269029 Merge: 01f632db46 e8ef26196c Author: Julien Tant Date: Tue Feb 25 10:05:43 2025 -0700 Merge branch 'master' into MM-62695 commit 01f632db46cf0eeeea0bac337b10c66fad815a24 Author: Julien Tant Date: Thu Feb 13 19:16:33 2025 -0700 removed unused i18n commit 0214d7eb947d3d8ecfde169cb07c210ecbfc9c92 Merge: db0e371ca2 68c11e9ecb Author: Julien Tant Date: Thu Feb 13 19:15:08 2025 -0700 Merge remote-tracking branch 'origin/master' into MM-62695 commit db0e371ca239b5286e58ad264c46a8891eafbe9b Author: Julien Tant Date: Thu Feb 13 19:05:58 2025 -0700 generic options commit 3cd62774a1b54e5e9da0c1c5b2626de4f49116ca Merge: d95e5d9838 41e0f97176 Author: Julien Tant Date: Thu Feb 13 10:48:47 2025 -0700 Merge remote-tracking branch 'origin/master' into MM-62695 commit d95e5d9838b9b00bd4ef9ec066df03cfc52596d7 Author: Julien Tant Date: Wed Feb 12 17:07:28 2025 -0700 style commit 2256076ed174b25a5decc2631ee6abaa9fa1a3c1 Author: Julien Tant (aider) Date: Wed Feb 12 17:06:22 2025 -0700 refactor: Make color field optional in custom profile attributes commit 7382b8ecb3964083d24d879210aa3d137983b404 Author: Julien Tant Date: Wed Feb 12 16:40:10 2025 -0700 fix api test commit a54c2d653f9a2e46d989181f9ef5ffd3dcbdb8c0 Author: Julien Tant Date: Wed Feb 12 16:02:27 2025 -0700 lint commit 9d2e3f78f6ba836b599df7f3cdd4766bf47cd600 Author: Julien Tant Date: Wed Feb 12 15:50:31 2025 -0700 use custom types rather than string commit 4624df52cbda3cb9b0f8fa60b7a3d4f5ecf1c845 Author: Julien Tant (aider) Date: Wed Feb 12 15:40:52 2025 -0700 refactor: Use consistent "ValidateCPAField" in error messages for custom profile attributes commit 42716170b7158cc4d3ce06cfef36046be6290def Author: Julien Tant Date: Wed Feb 12 15:36:04 2025 -0700 fix casing in custom profilte attributes test commit bcca3d87c47efcf95fcea2d1b380ce8aee89f7d4 Author: Julien Tant (aider) Date: Wed Feb 12 15:33:26 2025 -0700 refactor: Modify CustomProfileAttributesSelectOption to use lowercase JSON keys commit 861e12c1fcaedb69d331120bd4ae58757df1fae1 Author: Julien Tant Date: Wed Feb 12 15:29:23 2025 -0700 i18n commit a44f6f40eb5babab5f72a49078bec110a4645ddb Author: Julien Tant Date: Wed Feb 12 15:21:08 2025 -0700 improve test commit b2f002016fb7438a4507f9cb1cc236a43fb71e27 Author: Julien Tant (aider) Date: Wed Feb 12 15:10:00 2025 -0700 test: Add test case for preserving option IDs when patching select field commit aac155ef23866d7b85ef49483eec5b610b2a8bc0 Author: Julien Tant Date: Wed Feb 12 15:04:18 2025 -0700 don't do validation in constructor commit a88c092768fb0c46b76e72d6e27f417d3f25ba86 Author: Julien Tant (aider) Date: Wed Feb 12 15:00:10 2025 -0700 feat: Enhance ID validation and trimming in custom profile attributes commit b6fb5f274a97f9de4d80899a406b4397d2a4983e Author: Julien Tant (aider) Date: Wed Feb 12 14:57:24 2025 -0700 test: Add test cases for preserving IDs in custom profile attributes commit bbb7f3e2610f4ebc96c6043504710f359d48a733 Author: Julien Tant (aider) Date: Wed Feb 12 14:56:15 2025 -0700 refactor: Update CustomProfileAttributesSelectOption constructor to prioritize ID parameter commit dbd1728a14fd708e60a3c1180b21a155221a032a Author: Julien Tant Date: Wed Feb 12 14:50:14 2025 -0700 feat: Add validation for custom profile attributes fields commit e87689571bde77c2881f50af537930be9355f815 Author: Julien Tant (aider) Date: Wed Feb 12 14:44:15 2025 -0700 test: Add comprehensive test for NewCustomProfileAttributesSelectOptionFromMap commit a2292d44ff2cc496e36633440c3d0cbdb1930e45 Author: Julien Tant (aider) Date: Wed Feb 12 14:43:14 2025 -0700 feat: Add support for lowercase and title case keys in custom profile attributes map commit 47ca1848767d597f8305c4c183a68be476c034b9 Author: Julien Tant Date: Wed Feb 12 14:43:12 2025 -0700 fix: Update custom profile attributes map keys to use capitalized names commit e8de7dfc6d01c73064c10f65e0ecc86c4b2ae320 Author: Julien Tant (aider) Date: Wed Feb 12 14:01:31 2025 -0700 feat: Add comprehensive test cases for custom profile attributes field validation commit e798a53170d0aedbe25916cdaef3a11ac4191fd5 Author: Julien Tant Date: Wed Feb 12 13:56:25 2025 -0700 add default visibility commit e54ea2ba2f19bbcea06c9deed1a28923a2db5532 Author: Julien Tant Date: Wed Feb 12 13:22:04 2025 -0700 fix tests commit db1839a6e9b7d6a86ae334fa7bcae5e3aa622295 Author: Julien Tant (aider) Date: Wed Feb 12 13:18:51 2025 -0700 feat: Add index number to validation error messages in custom profile attributes commit 35e29a0dfd3fb3cc09b7a2ec9234791719224c01 Author: Julien Tant Date: Wed Feb 12 13:18:49 2025 -0700 refactor: Add validation and creation methods for custom profile attributes commit eac47527d27607fef30e840175c6ddb855445467 Author: Julien Tant (aider) Date: Wed Feb 12 12:49:00 2025 -0700 feat: Add validation to prevent empty custom profile attribute options commit 1baece68f945de3d8fe8d4fe9df0ebcb243ba5f7 Author: Julien Tant Date: Wed Feb 12 12:48:36 2025 -0700 refactor: Rename NewCustomProfileAttributeSelectOption to NewCustomProfileAttributesSelectOption commit 43710b018ab102f74ecaf2339e29593ae5dd3f55 Author: Julien Tant (aider) Date: Wed Feb 12 12:46:43 2025 -0700 refactor: Replace map[string]bool with map[string]struct{} for key existence check commit 3263b04478cf69533e298535f75a9d6f252e46de Author: Julien Tant (aider) Date: Wed Feb 12 12:46:06 2025 -0700 feat: Add IsValid method to validate CustomProfileAttributesSelectOptions commit 4531710f560fbc22ab76f6a67597e154fec3c896 Author: Julien Tant Date: Wed Feb 12 12:46:04 2025 -0700 refactor: Fix typo in custom profile attributes select option function name commit c1c821275c574e15a0e03987098891e191e9aa55 Author: Julien Tant Date: Wed Feb 12 12:42:50 2025 -0700 gofmt commit 328c898a3f0c50f8b0dd83aa37c84c099342a8a4 Author: Julien Tant (aider) Date: Wed Feb 12 12:41:14 2025 -0700 refactor: Trim spaces from name and color in custom profile attribute select option constructor commit b924431499b1b1472f377fd6c8bc1e726e8857c3 Author: Julien Tant Date: Wed Feb 12 12:41:12 2025 -0700 feat: Add custom profile attributes model with validation and constants commit 463ad161c5141cc8994bab0278cdc1df54f4b4e3 Author: Julien Tant (aider) Date: Wed Feb 12 12:40:19 2025 -0700 test: Add unit tests for custom profile attributes select options * sort_order int temp * name, type menu, dot menu * values add/remove * disabled when deleted * - max length - full height - validate options - clear attrs.options when not select/multiselect * Revert "sort_order int temp" This reverts commit ed675983c21965d7a9534e6c7b4eb38b8a751002. * Revert "Squashed commit of the following:" This reverts commit faf8b01169f0c285b7c77333f601e3cc7a1f4f18. * field values test * test type menu * test table * fix dot menu item id * test delete modal * test dot menu * fix system_properties test * fix user_properties_utils tests * fix attr sort_order type * i18n * fix lint * fix test types * lint scss * wip * fix attrs json * ldap/saml links * duplicate * menuitemlink - blockable * user_properties_values tests * add blockable link test * user_properties_dot_menu test * types * lint styling * fix test * i18n * self-review changes: - enforce field limit for duplicate flow - useMemo dep - disable non-text field type options when ldap/saml syncing active --- .../blockable_link/blockable_link.test.tsx | 99 +++++++++++++++++++ .../user_properties_dot_menu.test.tsx | 52 +++++++++- .../user_properties_dot_menu.tsx | 56 ++++++++++- .../user_properties_table.test.tsx | 5 + .../user_properties_table.tsx | 35 +++++-- .../user_properties_type_menu.test.tsx | 28 ++++++ .../user_properties_type_menu.tsx | 17 +++- .../user_properties_utils.ts | 33 +++++-- .../user_properties_values.scss | 38 +++---- .../user_properties_values.test.tsx | 73 ++++++++++++++ .../user_properties_values.tsx | 53 +++++++++- webapp/channels/src/components/menu/index.ts | 3 +- .../src/components/menu/menu_item_link.tsx | 52 ++++++++++ .../src/components/new_search/select_team.tsx | 2 +- webapp/channels/src/i18n/en.json | 7 ++ 15 files changed, 506 insertions(+), 47 deletions(-) create mode 100644 webapp/channels/src/components/admin_console/blockable_link/blockable_link.test.tsx create mode 100644 webapp/channels/src/components/menu/menu_item_link.tsx 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 = [ -