MM-62696, MM-62697: CPA Ext. Types & Visibility in System Console (#30511)

* 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

* disable email type

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Caleb Roseland
2025-04-02 13:46:28 -05:00
коммит произвёл GitHub
родитель c3f02b8ebc
Коммит 035b9ad402
22 изменённых файлов: 1566 добавлений и 132 удалений

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

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