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 <build@mattermost.com>
Date:   Fri Mar 14 21:15:22 2025 +0200

    Merge branch 'master' into MM-62695

commit 4940da4326ff787dd43fc486f06be415257181ae
Author: Julien Tant <julien@craftyx.fr>
Date:   Fri Mar 14 12:14:45 2025 -0700

    manage error the same way property field and value do

commit cd9ec590264ca3751a55fc0926318cbb6f46d471
Author: Julien Tant <julien@craftyx.fr>
Date:   Fri Mar 14 12:05:02 2025 -0700

    sanitize and validate

commit 2672e90b06331ff2e31a7807737cc1feff1c1beb
Author: Julien Tant <julien@craftyx.fr>
Date:   Fri Mar 14 12:02:20 2025 -0700

    use Len test method

commit d101950d655c7d6fd7a668a15554cc52a1d42667
Author: Julien Tant <julien@craftyx.fr>
Date:   Fri Mar 14 11:59:12 2025 -0700

    rearrange consts

commit ed1b8f66fb08f7be38615ced74282380d5d680b7
Author: Julien Tant <julien@craftyx.fr>
Date:   Fri Mar 14 11:58:56 2025 -0700

    rename CPASortOrder method

commit d3bc303fa3c9694d2f6d1531186f4fbe69efb956
Author: Julien Tant <julien@craftyx.fr>
Date:   Fri Mar 14 11:54:24 2025 -0700

    rename CustomProfileAttributes in method to CPA

commit b2323d44a6c3f31ea7c798f8a88c62878d5d2cdf
Author: Julien Tant <julien@craftyx.fr>
Date:   Fri Mar 14 11:54:02 2025 -0700

    Add SAML and LDAP attr

commit d411ae9da5a078cfbac60c5662bee27622ff31e5
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Mar 12 11:58:21 2025 -0700

    i18n

commit 27bc74c71462ae08d496104c908454ebc4f2def3
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Mar 12 11:11:23 2025 -0700

    fix TestDeleteCPAField test

commit 0d77071225d8575ace89cbe38c6c06fa94d7696b
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Mar 12 10:28:49 2025 -0700

    err->appErr

commit fe87a68caa49c7264c20c32cd4f598ffd18d503f
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Mar 12 10:22:13 2025 -0700

    i18n

commit 3b3ddf978fce66d9506416a10c03b21d6a12e9d0
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Mar 12 10:21:12 2025 -0700

    tests

commit 2f898bd53b832dea60dcdb960a71cd47709cddf8
Author: Julien Tant <julien@craftyx.fr>
Date:   Tue Mar 11 15:09:12 2025 -0700

    add validation and tests

commit a8c20841af6e82a6bf4f5dde6324b5e56c4d754e
Author: Julien Tant <julien@craftyx.fr>
Date:   Tue Mar 11 13:45:53 2025 -0700

    code style

commit 5bb3868b2ce93b9eb6e9d0836c597fa1176fbbbd
Merge: a4180d5d8f 7c25de2cff
Author: Mattermost Build <build@mattermost.com>
Date:   Tue Mar 11 22:24:06 2025 +0200

    Merge branch 'master' into MM-62695

commit a4180d5d8ff5e23a7a0a73b08806d37289e076ce
Author: Julien Tant <julien@craftyx.fr>
Date:   Tue Feb 25 11:53:54 2025 -0700

    use CPAField

commit 988177024ceebe73750ce48c40ea0a9ca6db75d6
Author: Julien Tant (aider) <julien@craftyx.fr>
Date:   Tue Feb 25 10:52:47 2025 -0700

    refactor: Move validateCustomProfileAttributesField to Validate method on CPAField struct

commit 783e64472c7ef3c1e33ce9a94b0716e96a72b105
Author: Julien Tant <julien@craftyx.fr>
Date:   Tue Feb 25 10:52:45 2025 -0700

    refactor: Improve custom profile attributes field handling and validation

commit aee06af59c748e74e5dc7718c1529527ac8c052a
Author: Julien Tant (aider) <julien@craftyx.fr>
Date:   Tue Feb 25 10:50:29 2025 -0700

    test: Add test case for CPA field with empty attributes

commit 7ab4455f9feb88826fcf6abb3b93a8a7bb53ed3e
Author: Julien Tant <julien@craftyx.fr>
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) <julien@craftyx.fr>
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) <julien@craftyx.fr>
Date:   Tue Feb 25 10:44:39 2025 -0700

    test: Add tests for NewCPAFieldFromPropertyField and CPAFieldToPropertyField

commit 335f6b5c8901b6ecdc1e9f031a81695ffc269029
Merge: 01f632db46 e8ef26196c
Author: Julien Tant <julien@craftyx.fr>
Date:   Tue Feb 25 10:05:43 2025 -0700

    Merge branch 'master' into MM-62695

commit 01f632db46cf0eeeea0bac337b10c66fad815a24
Author: Julien Tant <julien@craftyx.fr>
Date:   Thu Feb 13 19:16:33 2025 -0700

    removed unused i18n

commit 0214d7eb947d3d8ecfde169cb07c210ecbfc9c92
Merge: db0e371ca2 68c11e9ecb
Author: Julien Tant <julien@craftyx.fr>
Date:   Thu Feb 13 19:15:08 2025 -0700

    Merge remote-tracking branch 'origin/master' into MM-62695

commit db0e371ca239b5286e58ad264c46a8891eafbe9b
Author: Julien Tant <julien@craftyx.fr>
Date:   Thu Feb 13 19:05:58 2025 -0700

    generic options

commit 3cd62774a1b54e5e9da0c1c5b2626de4f49116ca
Merge: d95e5d9838 41e0f97176
Author: Julien Tant <julien@craftyx.fr>
Date:   Thu Feb 13 10:48:47 2025 -0700

    Merge remote-tracking branch 'origin/master' into MM-62695

commit d95e5d9838b9b00bd4ef9ec066df03cfc52596d7
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Feb 12 17:07:28 2025 -0700

    style

commit 2256076ed174b25a5decc2631ee6abaa9fa1a3c1
Author: Julien Tant (aider) <julien@craftyx.fr>
Date:   Wed Feb 12 17:06:22 2025 -0700

    refactor: Make color field optional in custom profile attributes

commit 7382b8ecb3964083d24d879210aa3d137983b404
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Feb 12 16:40:10 2025 -0700

    fix api test

commit a54c2d653f9a2e46d989181f9ef5ffd3dcbdb8c0
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Feb 12 16:02:27 2025 -0700

    lint

commit 9d2e3f78f6ba836b599df7f3cdd4766bf47cd600
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Feb 12 15:50:31 2025 -0700

    use custom types rather than string

commit 4624df52cbda3cb9b0f8fa60b7a3d4f5ecf1c845
Author: Julien Tant (aider) <julien@craftyx.fr>
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 <julien@craftyx.fr>
Date:   Wed Feb 12 15:36:04 2025 -0700

    fix casing in custom profilte attributes test

commit bcca3d87c47efcf95fcea2d1b380ce8aee89f7d4
Author: Julien Tant (aider) <julien@craftyx.fr>
Date:   Wed Feb 12 15:33:26 2025 -0700

    refactor: Modify CustomProfileAttributesSelectOption to use lowercase JSON keys

commit 861e12c1fcaedb69d331120bd4ae58757df1fae1
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Feb 12 15:29:23 2025 -0700

    i18n

commit a44f6f40eb5babab5f72a49078bec110a4645ddb
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Feb 12 15:21:08 2025 -0700

    improve test

commit b2f002016fb7438a4507f9cb1cc236a43fb71e27
Author: Julien Tant (aider) <julien@craftyx.fr>
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 <julien@craftyx.fr>
Date:   Wed Feb 12 15:04:18 2025 -0700

    don't do validation in constructor

commit a88c092768fb0c46b76e72d6e27f417d3f25ba86
Author: Julien Tant (aider) <julien@craftyx.fr>
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) <julien@craftyx.fr>
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) <julien@craftyx.fr>
Date:   Wed Feb 12 14:56:15 2025 -0700

    refactor: Update CustomProfileAttributesSelectOption constructor to prioritize ID parameter

commit dbd1728a14fd708e60a3c1180b21a155221a032a
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Feb 12 14:50:14 2025 -0700

    feat: Add validation for custom profile attributes fields

commit e87689571bde77c2881f50af537930be9355f815
Author: Julien Tant (aider) <julien@craftyx.fr>
Date:   Wed Feb 12 14:44:15 2025 -0700

    test: Add comprehensive test for NewCustomProfileAttributesSelectOptionFromMap

commit a2292d44ff2cc496e36633440c3d0cbdb1930e45
Author: Julien Tant (aider) <julien@craftyx.fr>
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 <julien@craftyx.fr>
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) <julien@craftyx.fr>
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 <julien@craftyx.fr>
Date:   Wed Feb 12 13:56:25 2025 -0700

    add default visibility

commit e54ea2ba2f19bbcea06c9deed1a28923a2db5532
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Feb 12 13:22:04 2025 -0700

    fix tests

commit db1839a6e9b7d6a86ae334fa7bcae5e3aa622295
Author: Julien Tant (aider) <julien@craftyx.fr>
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 <julien@craftyx.fr>
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) <julien@craftyx.fr>
Date:   Wed Feb 12 12:49:00 2025 -0700

    feat: Add validation to prevent empty custom profile attribute options

commit 1baece68f945de3d8fe8d4fe9df0ebcb243ba5f7
Author: Julien Tant <julien@craftyx.fr>
Date:   Wed Feb 12 12:48:36 2025 -0700

    refactor: Rename NewCustomProfileAttributeSelectOption to NewCustomProfileAttributesSelectOption

commit 43710b018ab102f74ecaf2339e29593ae5dd3f55
Author: Julien Tant (aider) <julien@craftyx.fr>
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) <julien@craftyx.fr>
Date:   Wed Feb 12 12:46:06 2025 -0700

    feat: Add IsValid method to validate CustomProfileAttributesSelectOptions

commit 4531710f560fbc22ab76f6a67597e154fec3c896
Author: Julien Tant <julien@craftyx.fr>
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 <julien@craftyx.fr>
Date:   Wed Feb 12 12:42:50 2025 -0700

    gofmt

commit 328c898a3f0c50f8b0dd83aa37c84c099342a8a4
Author: Julien Tant (aider) <julien@craftyx.fr>
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 <julien@craftyx.fr>
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) <julien@craftyx.fr>
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
Этот коммит содержится в:
Caleb Roseland
2025-04-22 05:55:05 -05:00
коммит произвёл GitHub
родитель ea217e2352
Коммит 79561c44c2
15 изменённых файлов: 506 добавлений и 47 удалений

Просмотреть файл

@@ -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(
<MemoryRouter>
<BlockableLink {...defaultProps}/>
</MemoryRouter>,
);
expect(screen.getByText('Link Text')).toBeInTheDocument();
expect(screen.getByRole('link')).toHaveAttribute('href', '/admin_console/test');
});
test('should navigate directly when not blocked', () => {
render(
<MemoryRouter>
<BlockableLink {...defaultProps}/>
</MemoryRouter>,
);
fireEvent.click(screen.getByText('Link Text'));
expect(defaultProps.actions.deferNavigation).not.toHaveBeenCalled();
});
test('should defer navigation when blocked', () => {
const blockedProps = {
...defaultProps,
blocked: true,
};
render(
<MemoryRouter>
<BlockableLink {...blockedProps}/>
</MemoryRouter>,
);
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(
<MemoryRouter>
<BlockableLink {...onClickProps}/>
</MemoryRouter>,
);
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(
<MemoryRouter>
<BlockableLink {...customProps}/>
</MemoryRouter>,
);
const link = screen.getByRole('link');
expect(link).toHaveClass('custom-class');
expect(link).toHaveAttribute('id', 'custom-id');
expect(link).toHaveAttribute('data-testid', 'custom-test-id');
});
});

Просмотреть файл

@@ -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<ComponentProps<typeof DotMenu>>) => {
return renderWithContext(
(
<div>
<DotMenu
field={field}
canCreate={true}
{...dotMenuProps}
updateField={updateField}
deleteField={deleteField}
createField={createField}
/>
<ModalController/>
</div>
@@ -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();

Просмотреть файл

@@ -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 = ({
)}
/>
</Menu.SubMenu>
<Menu.LinkItem
id={`${menuId}_link_ad-ldap`}
to={`/admin_console/authentication/ldap#custom_profile_attribute-${field.name}`}
leadingElement={<SyncIcon size={18}/>}
labels={(
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.ad_ldap.link_property.label'
defaultMessage={'Link property to AD/LDAP'}
/>
)}
/>
<Menu.LinkItem
id={`${menuId}_link_ad-ldap`}
to={`/admin_console/authentication/saml#custom_profile_attribute-${field.name}`}
leadingElement={<SyncIcon size={18}/>}
labels={(
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.saml.link_property.label'
defaultMessage={'Link property to SAML'}
/>
)}
/>
<Menu.Separator/>
{canCreate && (
<Menu.Item
id={`${menuId}_duplicate`}
onClick={handleDuplicate}
leadingElement={<ContentCopyIcon size={18}/>}
labels={(
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.duplicate.label'
defaultMessage={'Duplicate property'}
/>
)}
/>
)}
<Menu.Item
id={`${menuId}_delete`}
onClick={handleDelete}
isDestructive={true}
leadingElement={<TrashCanOutlineIcon size={18}/>}
labels={(
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.delete.label'
defaultMessage={'Delete property'}
/>
)}
leadingElement={<TrashCanOutlineIcon size={18}/>}
/>
</Menu.Container>
);

Просмотреть файл

@@ -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(
<UserPropertiesTable
data={collection}
canCreate={true}
createField={createField}
updateField={updateField}
deleteField={deleteField}
reorderField={reorderField}
@@ -149,6 +152,8 @@ describe('UserPropertiesTable', () => {
renderWithContext(
<UserPropertiesTable
data={collection}
canCreate={true}
createField={createField}
updateField={updateField}
deleteField={deleteField}
reorderField={reorderField}

Просмотреть файл

@@ -25,11 +25,8 @@ import UserPropertyValues from './user_properties_values';
import {AdminConsoleListTable} from '../list_table';
type Props = {
data: UserPropertyFields;
}
type FieldActions = {
createField: (field: UserPropertyField) => 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 => {
<>
<UserPropertiesTable
data={userPropertyFields}
canCreate={canCreate}
createField={itemOps.create}
updateField={itemOps.update}
deleteField={itemOps.delete}
reorderField={itemOps.reorder}
/>
{nonDeletedCount < Constants.MAX_CUSTOM_ATTRIBUTES && (
<LinkButton onClick={itemOps.create}>
{canCreate && (
<LinkButton onClick={create}>
<PlusIcon size={16}/>
<FormattedMessage
id='admin.system_properties.user_properties.add_property'
@@ -82,7 +87,19 @@ export const useUserPropertiesTable = (): SectionHook => {
};
};
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<UserPropertyField>();
@@ -216,6 +233,8 @@ export function UserPropertiesTable({data: collection, updateField, deleteField,
<ActionsRoot>
<DotMenu
field={row.original}
canCreate={canCreate}
createField={createField}
updateField={updateField}
deleteField={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,

Просмотреть файл

@@ -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,

Просмотреть файл

@@ -70,7 +70,7 @@ const SelectType = (props: Props) => {
}}
>
{[
<Menu.Input
<Menu.InputItem
key='filter_types'
id='filter_types'
type='text'
@@ -82,18 +82,22 @@ 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 (
<Menu.Item
id={id}
key={id}
role='menuitemradio'
forceCloseOnSelect={true}
disabled={disabled}
aria-checked={id === currentTypeDescriptor.id}
onClick={() => handleTypeChange(descriptor)}
labels={<FormattedMessage {...label}/>}
@@ -131,7 +135,9 @@ type TypeDescriptor = {
valueType: FieldValueType;
icon: ComponentType<IconProps>;
label: MessageDescriptor;
disabled?: boolean;
hidden?: boolean;
canSync?: boolean; // ldap/saml
};
const TYPE_DESCRIPTOR: IDMappedObjects<TypeDescriptor> = {
@@ -144,10 +150,11 @@ const TYPE_DESCRIPTOR: IDMappedObjects<TypeDescriptor> = {
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,

Просмотреть файл

@@ -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 = <T extends {delete_at: number; create_at: number}
export const newPendingId = () => `${PENDING}${generateId()}`;
export const newPendingField = (patch: UserPropertyFieldPatch & Pick<UserPropertyField, 'name'>): 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<UserPropert
visibility: 'when_set' satisfies FieldVisibility,
sort_order: 0,
value_type: '' satisfies FieldValueType,
...patch.attrs,
...attrs,
},
};
};

Просмотреть файл

@@ -1,32 +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;
.user-property-field-values {
display: flex;
align-items: center;
color: rgba(var(--center-channel-color-rgb), 0.75);
gap: 4px;
}
.user-property-field-values__chip-link {
padding: 4px 12px;
border-radius: 12px;
background: rgba(var(--center-channel-color-rgb), 0.08);
color: var(--center-channel-color);
font-size: 12px;
font-style: normal;
font-weight: 600;
&:hover,
&:focus {
border-color: transparent;
box-shadow: none;
text-decoration: none;
}
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.04)
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;
}
}

Просмотреть файл

@@ -143,4 +143,77 @@ describe('UserPropertyValues', () => {
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();
});
});

Просмотреть файл

@@ -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 && (
<BlockableLink
className='user-property-field-values__chip-link'
to={`/admin_console/authentication/ldap#custom_profile_attribute-${field.name}`}
key={`${field.name}-ldap`}
data-testid={`user-property-field-values__ldap-${field.name}`}
>
<FormattedMessage
id='admin.system_properties.user_properties.table.values.synced_with.ldap'
defaultMessage='AD/LDAP: {propertyName}'
values={{propertyName: field.attrs.ldap}}
/>
</BlockableLink>
),
field.attrs.saml && (
<BlockableLink
className='user-property-field-values__chip-link'
to={`/admin_console/authentication/saml#custom_profile_attribute-${field.name}`}
key={`${field.name}-saml`}
data-testid={`user-property-field-values__saml-${field.name}`}
>
<FormattedMessage
id='admin.system_properties.user_properties.table.values.synced_with.saml'
defaultMessage='SAML: {propertyName}'
values={{propertyName: field.attrs.saml}}
/>
</BlockableLink>
),
].filter(Boolean);
return (
<span className='user-property-field-values'>
<SyncIcon size={18}/>
<FormattedMessage
id='admin.system_properties.user_properties.table.values.synced_with'
defaultMessage='Synced with: {syncedProperties}'
values={{syncedProperties: <FormattedList value={syncedProperties}/>}}
/>
</span>
);
}
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,

Просмотреть файл

@@ -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';

Просмотреть файл

@@ -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 (
<MenuItem
onClick={handleClick}
{...otherProps}
/>
);
}

Просмотреть файл

@@ -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 = [
<Menu.Input
<Menu.InputItem
key='filter_teams'
id='search_teams'
type='text'

Просмотреть файл

@@ -2592,7 +2592,11 @@
"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.ad_ldap.link_property.label": "Link property to AD/LDAP",
"admin.system_properties.user_properties.dotmenu.delete.label": "Delete property",
"admin.system_properties.user_properties.dotmenu.duplicate.label": "Duplicate property",
"admin.system_properties.user_properties.dotmenu.duplicate.name_copy": "{fieldName} (copy)",
"admin.system_properties.user_properties.dotmenu.saml.link_property.label": "Link property to SAML",
"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",
@@ -2615,6 +2619,9 @@
"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.table.values.synced_with": "Synced with: {syncedProperties}",
"admin.system_properties.user_properties.table.values.synced_with.ldap": "AD/LDAP: {propertyName}",
"admin.system_properties.user_properties.table.values.synced_with.saml": "SAML: {propertyName}",
"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",