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