MM-62172: CPA/User Properties - System Console (#29672)

* system properties - user properties

* update

* fix processing

* refinements, fix cancel changes, fix type inclusion when saving/creating, i18n

* fix:
- property name value trimmed
- name unique validation
- name required validation
- name max-length
- user properties section titlecase
- new property default incremented name
- new property auto-focus/select
- max fields count
- table design/styling

* add useUserPropertyFields test
Этот коммит содержится в:
Caleb Roseland
2025-01-14 11:42:46 -06:00
коммит произвёл GitHub
родитель e400e67732
Коммит 0085826203
23 изменённых файлов: 1407 добавлений и 22 удалений

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

@@ -93,6 +93,7 @@ import SecureConnectionDetail from './secure_connections/secure_connection_detai
import ServerLogs from './server_logs';
import {searchableStrings as serverLogsSearchableStrings} from './server_logs/logs';
import SessionLengthSettings, {searchableStrings as sessionLengthSearchableStrings} from './session_length_settings';
import SystemProperties, {searchableStrings as systemPropertiesSearchableStrings} from './system_properties';
import SystemRoles from './system_roles';
import SystemRole from './system_roles/system_role';
import SystemUserDetail from './system_user_detail';
@@ -2172,6 +2173,19 @@ const AdminDefinition: AdminDefinitionType = {
],
},
},
system_properties: {
url: 'site_config/system_properties',
title: defineMessage({id: 'admin.sidebar.system_properties', defaultMessage: 'System Properties'}),
searchableStrings: systemPropertiesSearchableStrings,
isHidden: it.not(it.all(
it.licensedForSku(LicenseSkus.Enterprise),
it.configIsTrue('FeatureFlags', 'CustomProfileAttributes'),
)),
schema: {
id: 'SystemProperties',
component: SystemProperties,
},
},
localization: {
url: 'site_config/localization',
title: defineMessage({id: 'admin.sidebar.localization', defaultMessage: 'Localization'}),

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

@@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import type {MouseEvent} from 'react';
type Props = {
id?: string;
activeClassName?: string;
// Bool whether navigation is blocked
blocked: boolean;
actions: {
// Function for deferring navigation while blocked
deferNavigation: (func: () => void) => void;
};
children?: React.ReactNode;
className?: string;
onClick?: (e: React.MouseEvent) => void;
onCancelConfirmed: () => void;
};
const BlockableButton = ({blocked, actions, onClick, onCancelConfirmed, ...restProps}: Props) => {
const handleClick = useCallback((e: MouseEvent) => {
onClick?.(e);
if (blocked) {
e.preventDefault();
actions.deferNavigation(() => {
onCancelConfirmed();
});
}
}, [actions, blocked, onClick, onCancelConfirmed]);
return (
<button
{...restProps}
onClick={handleClick}
/>
);
};
export default React.memo(BlockableButton);

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

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {deferNavigation} from 'actions/admin_actions';
import {getNavigationBlocked} from 'selectors/views/admin';
import type {GlobalState} from 'types/store';
import BlockableButton from './blockable_button';
function mapStateToProps(state: GlobalState) {
return {
blocked: getNavigationBlocked(state),
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
deferNavigation,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(BlockableButton);

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

@@ -24,7 +24,7 @@ import BlockableLink from 'components/admin_console/blockable_link';
import {GroupProfileAndSettings} from 'components/admin_console/group_settings/group_details/group_profile_and_settings';
import GroupTeamsAndChannels from 'components/admin_console/group_settings/group_details/group_teams_and_channels';
import GroupUsers from 'components/admin_console/group_settings/group_details/group_users';
import SaveChangesPanel from 'components/admin_console/team_channel_settings/save_changes_panel';
import SaveChangesPanel from 'components/admin_console/save_changes_panel';
import ChannelSelectorModal from 'components/channel_selector_modal';
import FormError from 'components/form_error';
import TeamSelectorModal from 'components/team_selector_modal';

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

@@ -23,7 +23,7 @@ import EnableSectionContent from './enable_section';
import {isIPAddressInRanges} from './ip_filtering_utils';
import SaveConfirmationModal from './save_confirmation_modal';
import SaveChangesPanel from '../team_channel_settings/save_changes_panel';
import SaveChangesPanel from '../save_changes_panel';
import './ip_filtering.scss';

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

@@ -4,6 +4,9 @@
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {Either} from '@mattermost/types/utilities';
import BlockableButton from 'components/admin_console/blockable_button';
import BlockableLink from 'components/admin_console/blockable_link';
import SaveButton from 'components/save_button';
@@ -11,13 +14,16 @@ type Props = {
saving: boolean;
saveNeeded: boolean;
onClick: () => void;
cancelLink: string;
serverError?: JSX.Element | string;
isDisabled?: boolean;
savingMessage?: string;
};
} & Either<{
cancelLink: string;
}, {
onCancel: () => void;
}>;
const SaveChangesPanel = ({saveNeeded, onClick, saving, serverError, cancelLink, isDisabled, savingMessage}: Props) => {
const SaveChangesPanel = ({saveNeeded, onClick, saving, serverError, cancelLink, onCancel, isDisabled, savingMessage}: Props) => {
const {formatMessage} = useIntl();
return (
<div className='admin-console-save'>
@@ -27,19 +33,29 @@ const SaveChangesPanel = ({saveNeeded, onClick, saving, serverError, cancelLink,
onClick={onClick}
savingMessage={savingMessage ?? formatMessage({id: 'admin.team_channel_settings.saving', defaultMessage: 'Saving Config...'})}
/>
{
cancelLink !== '' &&
<BlockableLink
id='cancelButtonSettings'
className='btn btn-quaternary'
to={cancelLink}
>
<FormattedMessage
id='admin.team_channel_settings.cancel'
defaultMessage='Cancel'
/>
</BlockableLink>
}
{cancelLink ? (
<BlockableLink
id='cancelButtonSettings'
className='btn btn-quaternary'
to={cancelLink}
>
<FormattedMessage
id='admin.team_channel_settings.cancel'
defaultMessage='Cancel'
/>
</BlockableLink>
) : onCancel && (
<BlockableButton
id='cancelButtonSettings'
className='btn btn-quaternary'
onCancelConfirmed={onCancel}
>
<FormattedMessage
id='admin.team_channel_settings.cancel'
defaultMessage='Cancel'
/>
</BlockableButton>
)}
<div className='error-message'>
{serverError}
</div>

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

@@ -47,7 +47,7 @@ import type {SharedChannelRemoteRow} from './utils';
import {getEditLocation, isConfirmed, isErrorState, isPendingState, useRemoteClusterEdit, useSharedChannelRemoteRows, useTeamOptions} from './utils';
import {AdminConsoleListTable} from '../list_table';
import SaveChangesPanel from '../team_channel_settings/save_changes_panel';
import SaveChangesPanel from '../save_changes_panel';
type Params = {
connection_id: 'create' | RemoteCluster['remote_id'];

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

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ReactNode} from 'react';
import React from 'react';
import styled, {css} from 'styled-components';
export const SectionHeading = styled.h3`
&&& {
margin-bottom: 8px;
}
`;
export const SectionHeader = styled.header.attrs({className: 'header'})<{$borderless?: boolean}>`
&&& {
padding: 24px 32px;
${({$borderless}) => !$borderless && css`
border-bottom: 1px solid var(--center-channel-color-12, rgba(63, 67, 80, 0.12));
`}
}
`;
export const SectionContent = styled.div.attrs({className: 'content'})<{$compact?: boolean}>`
&&& {
padding: ${({$compact}) => ($compact ? '24px 32px' : '48px 32px')};
border-bottom: 1px solid var(--center-channel-color-12, rgba(63, 67, 80, 0.12));
}
`;
export const AdminSection = styled.section.attrs({className: 'AdminPanel'})`
&& {
overflow: visible;
}
`;
export const AdminWrapper = (props: {children: ReactNode}) => {
return (
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
{props.children}
</div>
</div>
);
};
export const FieldInput = styled.input.attrs({className: 'form-control secure-connections-input'})<{$deleted?: boolean; $strong?: boolean; $borderless?: boolean}>`
font-weight: normal;
${({$borderless}) => $borderless && css`
&& {
border-color: transparent;
box-shadow: none;
}
`};
${({$deleted}) => $deleted && css`
&& {
color: #D24B4E;
text-decoration: line-through;
}
`};
${({$strong}) => $strong && css`
&& {
font-size: 14px;
font-style: normal;
font-weight: 600;
}
`};
`;
export const DangerText = styled.span`
color: #D24B4E;
`;
export const FieldDeleteButton = styled.button.attrs({className: 'btn btn-sm btn-transparent'})`
font-weight: normal;
`;
export const LinkButton = styled.button.attrs({className: 'btn btn-link'})`
font-weight: normal;
padding: 8px 16px !important;
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: 16px;
gap: 6px;
`;

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

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import SystemProperties from './system_properties';
export {searchableStrings} from './system_properties';
export default SystemProperties;

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

@@ -0,0 +1,151 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act} from '@testing-library/react-hooks';
import type {DeepPartial} from '@mattermost/types/utilities';
import {renderHookWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import {useOperation, useOperationStatus} from './section_utils';
function getBaseState(): DeepPartial<GlobalState> {
const currentUser = TestHelper.getUserMock();
const otherUser = TestHelper.getUserMock();
return {
entities: {
users: {
currentUserId: currentUser.id,
profiles: {
[currentUser.id]: currentUser,
[otherUser.id]: otherUser,
},
},
general: {
},
},
};
}
describe('useOperationStatus', () => {
it('should indicate loading true then false', async () => {
const {result, rerender} = renderHookWithContext(() => {
return useOperationStatus(true);
}, getBaseState());
const [status1, setStatus] = result.current;
expect(status1.loading).toBe(true);
expect(status1.error).toBe(undefined);
act(() => {
setStatus(false);
});
rerender();
const [status2] = result.current;
expect(status2.loading).toBe(false);
expect(status2.error).toBe(undefined);
});
it('should indicate loading true then false with error', async () => {
const {result, rerender} = renderHookWithContext(() => {
return useOperationStatus(true);
}, getBaseState());
const [status1, setStatus] = result.current;
expect(status1.loading).toBe(true);
expect(status1.error).toBe(undefined);
const testErr = new Error('test error');
act(() => {
setStatus(testErr);
});
rerender();
const [status2] = result.current;
expect(status2.loading).toBe(false);
expect(status2.error).toBe(testErr);
});
});
describe('useOperation', () => {
it('should run operation on command with response value and loading phases: false -> true -> false', async () => {
jest.useFakeTimers();
const testResolvingAsyncAction = jest.fn().mockImplementation((responseValue) => new Promise((r) => setTimeout(() => r(responseValue), 1000)));
const {result, rerender} = renderHookWithContext(() => {
return useOperation(testResolvingAsyncAction, false);
}, getBaseState());
const [doAction, status1] = result.current;
expect(status1.loading).toBe(false);
expect(status1.error).toBe(undefined);
expect(testResolvingAsyncAction).not.toBeCalled();
let actionPromise: Promise<any>;
await act(async () => {
actionPromise = doAction('test response value');
});
rerender();
const [, status2] = result.current;
expect(status2.loading).toBe(true);
expect(status2.error).toBe(undefined);
expect(testResolvingAsyncAction).toBeCalledTimes(1);
jest.runAllTimers();
await act(async () => {
await actionPromise;
const [, status3] = result.current;
expect(status3.loading).toBe(false);
expect(await actionPromise).toBe('test response value');
expect(status3.error).toBe(undefined);
});
});
it('should run operation on command with error and loading phases: false -> true -> false', async () => {
jest.useFakeTimers();
const testRejectingAsyncAction = jest.fn().mockImplementation(() => new Promise((resolve, reject) => setTimeout(() => reject(new Error('error somewhere')), 1000)));
const {result, rerender} = renderHookWithContext(() => {
return useOperation(testRejectingAsyncAction, false);
}, getBaseState());
const [doAction, status1] = result.current;
expect(status1.loading).toBe(false);
expect(status1.error).toBe(undefined);
let actionPromise: Promise<any>;
act(() => {
actionPromise = doAction();
});
rerender();
const [, status2] = result.current;
expect(status2.loading).toBe(true);
expect(status2.error).toBe(undefined);
jest.runAllTimers();
await act(async () => {
await actionPromise;
});
const [, status3] = result.current;
expect(status3.loading).toBe(false);
expect(status3.error).toBeTruthy();
});
});

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

@@ -0,0 +1,141 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ReactNode} from 'react';
import {useState, useCallback, useEffect} from 'react';
import {useSelector} from 'react-redux';
import type {GlobalState} from 'types/store';
export class BatchProcessingError<T = Error> extends Error {
cause?: {[key: string]: T};
}
export type SectionHook = SectionIO & {
content: ReactNode;
}
export type SectionIO = {
save: () => void;
cancel: () => void;
loading: boolean;
saving: boolean;
saveError: Error | undefined;
hasChanges: boolean;
isValid: boolean;
};
export type TLoadingState<TError extends Error> = boolean | TError;
const status = <T extends Error>(state: TLoadingState<T>) => {
const loading = state === true;
const error = state instanceof Error ? state : undefined;
return {loading, error};
};
/**
* Track loading and error states of an async operation.
* Error is cleared when setting loading status.
* @param initialState -
*/
export const useOperationStatus = <T extends Error>(initialState: TLoadingState<T>) => {
const [state, setState] = useState<TLoadingState<T>>(initialState);
return [status(state), setState] as const;
};
export type ReadOperations<T> = {
get: () => Promise<T | undefined>;
select?: (state: GlobalState) => T | undefined;
opts?: {forceInitialGet: boolean; initial?: Partial<T>};
}
export interface CollectionIO<T extends {id: string}> {
create?: (patch?: Partial<T>) => void;
update?: (item: T) => void;
delete?: ((item: T) => void) | ((id: T['id']) => void);
reorder?: (item: T, nextOrder: number) => void;
}
/**
* Monitored async operation with stateful error and loading status handling.
* @param initialStatus Provide default loading status. e.g. `true` if operation starts immediately or `false` if manually triggered.
*/
export function useOperation<T, TArgs extends unknown[], TErr extends Error>(op: (...args: TArgs) => T | undefined | Promise<T | undefined>, initialStatus = true) {
const [status, setStatus] = useOperationStatus<TErr>(initialStatus);
const doOp = useCallback(async (...args: TArgs) => {
setStatus(true);
try {
const response = await op(...args);
setStatus(false);
return response;
} catch (err) {
setStatus(err);
return undefined;
}
}, [op]);
return [doOp, status, setStatus] as const;
}
/**
* Use current thing from redux selector or async read operation
* @param ops Read
* @param ops.get Async operation to retrieve thing if not selected or needs hydration. e.g. a client4 method or dispatched action creator.
* @param ops.select Redux selector to retrieve thing from the store. Selected thing takes precedence over get-acquired thing.
* @param initial Provide the initial state of the thing, e.g. placeholder while the get operation is pending.
* @returns The thing and related meta. Use returned `get` action to forcefully or manually get thing.
* @remarks Current thing is designed to correspond to the real/saved thing e.g. most recent version of the thing on the server
*/
export function useThing<T>(ops: ReadOperations<T>, initial: T) {
const forceInitialGet = ops.opts?.forceInitialGet ?? true;
const selected = useSelector<GlobalState, T | undefined>((state) => ops.select?.(state));
const [data, setData] = useState<T>(initial);
const [get, status] = useOperation(ops.get, forceInitialGet || !selected);
useEffect(() => {
if (forceInitialGet || !selected) {
get().then((value) => {
if (value !== undefined) {
setData(value);
}
});
}
}, [forceInitialGet, selected, get, setData]);
return [selected ?? data, {...status, get, setData}] as const;
}
/**
* Use a pending thing to be saved in the future. Designed to be used with a corresponding {@link useThing}.
* Has built-in patching for simple/flat objects, or add your own layered write operations on top in your custom hook.
* @param data Current version or "source of truth" version of thing.
* @param opts.commit Action to save pending thing.
* @remarks After successfully committing, sync the resulting thing back to the current thing to reconcile or complete or the cycle and clear any diffs.
*/
export function usePendingThing<T extends Record<string, unknown>, TErr extends Error>(data: T, opts: {commit: (pending: T, current: T) => T | Promise<T>}) {
const [pending, setPending] = useState(data);
const hasChanges = pending !== data;
const [doCommit, {loading: saving, error}, setStatus] = useOperation<T, Parameters<typeof opts.commit>, TErr>(opts.commit, false);
useEffect(() => {
setPending(data);
}, [data]);
const apply = useCallback((update: T | ((current: T) => T)) => {
setPending((current) => (typeof update === 'function' ? update(current) : ({...current, ...update})));
}, [setPending]);
const reset = useCallback(() => {
setPending(data);
setStatus(false);
}, [setPending, data, setStatus]);
const commit = useCallback(() => {
return doCommit(pending, data);
}, [doCommit, pending, data]);
return [pending, {saving, error, hasChanges, apply, commit, reset}] as const;
}

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

@@ -0,0 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {FormattedMessage, defineMessages, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {setNavigationBlocked} from 'actions/admin_actions';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import {AdminSection, AdminWrapper, DangerText, SectionContent, SectionHeader, SectionHeading} from './controls';
import {useUserPropertiesTable} from './user_properties_table';
import SaveChangesPanel from '../save_changes_panel';
import type {SearchableStrings} from '../types';
type Props = {
disabled: boolean;
}
export default function SystemProperties(props: Props) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const userProperties = useUserPropertiesTable();
const saving = userProperties.saving;
const hasChanges = userProperties.hasChanges;
const isValid = userProperties.isValid;
const saveError = userProperties.saveError;
const handleSave = () => {
userProperties.save();
};
const handleCancel = () => {
userProperties.cancel();
};
useEffect(() => {
// block nav when changes are pending
dispatch(setNavigationBlocked(hasChanges));
}, [hasChanges]);
return (
<div
className='wrapper--fixed'
data-testid='systemProperties'
>
<AdminHeader>
<FormattedMessage {...msg.pageTitle}/>
</AdminHeader>
<AdminWrapper>
<AdminSection data-testid='user_properties'>
<SectionHeader>
<hgroup>
<FormattedMessage
tagName={SectionHeading}
id='admin.system_properties.user_properties.title'
defaultMessage='User Properties'
/>
<FormattedMessage
id='admin.system_properties.user_properties.subtitle'
defaultMessage='Customize the properties to show in user profiles'
/>
</hgroup>
</SectionHeader>
<SectionContent $compact={true}>
{userProperties.content}
</SectionContent>
</AdminSection>
</AdminWrapper>
<SaveChangesPanel
saving={saving}
saveNeeded={hasChanges}
onClick={handleSave}
onCancel={handleCancel}
serverError={saveError ? (
<FormattedMessage
tagName={DangerText}
id='admin.system_properties.details.saving_changes_error'
defaultMessage='There was an error while saving the configuration'
/>
) : undefined}
savingMessage={formatMessage({id: 'admin.system_properties.details.saving_changes', defaultMessage: 'Saving configuration…'})}
isDisabled={props.disabled || saving || !isValid}
/>
</div>
);
}
const msg = defineMessages({
pageTitle: {id: 'admin.sidebar.system_properties', defaultMessage: 'System Properties'},
});
export const searchableStrings: SearchableStrings = Object.values(msg);

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

@@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {GenericModal} from '@mattermost/components';
import type {UserPropertyField} from '@mattermost/types/properties';
import {openModal} from 'actions/views/modals';
import {ModalIdentifiers} from 'utils/constants';
type Props = {
name: string;
onConfirm: () => void;
onCancel?: () => void;
onExited: () => void;
}
const noop = () => {};
export const useUserPropertyFieldDelete = () => {
const dispatch = useDispatch();
const promptDelete = (field: UserPropertyField) => {
return new Promise<boolean>((resolve) => {
dispatch(openModal({
modalId: ModalIdentifiers.USER_PROPERTY_FIELD_DELETE,
dialogType: RemoveUserPropertyFieldModal,
dialogProps: {
name: field.name,
onConfirm: () => resolve(true),
},
}));
});
};
return {promptDelete} as const;
};
function RemoveUserPropertyFieldModal({
name,
onExited,
onCancel,
onConfirm,
}: Props) {
const {formatMessage} = useIntl();
const title = formatMessage({
id: 'admin.system_properties.confirm.delete.title',
defaultMessage: 'Delete {name} property',
}, {name});
const confirmButtonText = formatMessage({
id: 'admin.system_properties.confirm.delete.button',
defaultMessage: 'Delete',
});
const message = (
<FormattedMessage
id={'admin.system_properties.confirm.delete.text'}
defaultMessage={'Deleting this property will remove all user-defined values associated with it.'}
/>
);
return (
<GenericModal
confirmButtonText={confirmButtonText}
handleCancel={onCancel ?? noop}
handleConfirm={onConfirm}
modalHeaderText={title}
onExited={onExited}
compassDesign={true}
isDeleteModal={true}
>
{message}
</GenericModal>
);
}
export default RemoveUserPropertyFieldModal;

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

@@ -0,0 +1,379 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createColumnHelper, getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef} from '@tanstack/react-table';
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 {PlusIcon, TextBoxOutlineIcon, TrashCanOutlineIcon} from '@mattermost/compass-icons/components';
import type {UserPropertyField} from '@mattermost/types/properties';
import {collectionToArray} from '@mattermost/types/utilities';
import LoadingScreen from 'components/loading_screen';
import Constants from 'utils/constants';
import {DangerText, FieldDeleteButton, FieldInput, LinkButton} from './controls';
import type {SectionHook} from './section_utils';
import {useUserPropertyFieldDelete} from './user_properties_delete_modal';
import type {UserPropertyFields} from './user_properties_utils';
import {isCreatePending, useUserPropertyFields, ValidationWarningNameRequired, ValidationWarningNameUnique} from './user_properties_utils';
import {AdminConsoleListTable} from '../list_table';
type Props = {
data: UserPropertyFields;
}
type FieldActions = {
updateField: (field: UserPropertyField) => void;
deleteField: (id: string) => void;
}
export const useUserPropertiesTable = (): SectionHook => {
const [userPropertyFields, readIO, pendingIO, itemOps] = useUserPropertyFields();
const save = async () => {
const newData = await pendingIO.commit();
// reconcile - zero pending changes
if (newData && !newData.errors) {
readIO.setData(newData);
}
};
const content = readIO.loading ? (
<LoadingScreen/>
) : (
<>
<UserPropertiesTable
data={userPropertyFields}
updateField={itemOps.update}
deleteField={itemOps.delete}
/>
{userPropertyFields.order.length < Constants.MAX_CUSTOM_ATTRIBUTES && (
<LinkButton onClick={itemOps.create}>
<PlusIcon size={16}/>
<FormattedMessage
id='admin.system_properties.user_properties.add_property'
defaultMessage='Add property'
/>
</LinkButton>
)}
</>
);
return {
content,
loading: readIO.loading,
hasChanges: pendingIO.hasChanges,
isValid: !userPropertyFields.warnings,
save,
cancel: pendingIO.reset,
saving: pendingIO.saving,
saveError: pendingIO.error,
};
};
export function UserPropertiesTable({data: collection, updateField, deleteField}: Props & FieldActions) {
const data = collectionToArray(collection);
const col = createColumnHelper<UserPropertyField>();
const columns = useMemo<Array<ColumnDef<UserPropertyField, any>>>(() => {
return [
col.accessor('name', {
header: () => {
return (
<ColHeaderLeft>
<FormattedMessage
id='admin.system_properties.user_properties.table.property'
defaultMessage='Property'
/>
</ColHeaderLeft>
);
},
cell: ({getValue, row}) => {
const toDelete = row.original.delete_at !== 0;
const warningId = collection.warnings?.[row.original.id]?.name;
let warning;
if (warningId === ValidationWarningNameRequired) {
warning = (
<FormattedMessage
tagName={DangerText}
id='admin.system_properties.user_properties.table.validation.name_required'
defaultMessage='Please enter a property name.'
/>
);
} else if (warningId === ValidationWarningNameUnique) {
warning = (
<FormattedMessage
tagName={DangerText}
id='admin.system_properties.user_properties.table.validation.name_unique'
defaultMessage='Property names must be unique.'
/>
);
}
return (
<>
<EditableValue
strong={true}
value={getValue()}
deleted={toDelete}
borderless={!warning}
autoFocus={isCreatePending(row.original)}
setValue={(value: string) => {
updateField({...row.original, name: value.trim()});
}}
maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_NAME_LENGTH}
/>
{!toDelete && warning}
</>
);
},
enableHiding: false,
enableSorting: false,
}),
col.accessor('type', {
header: () => {
return (
<ColHeaderLeft>
<FormattedMessage
id='admin.system_properties.user_properties.table.type'
defaultMessage='Type'
/>
</ColHeaderLeft>
);
},
cell: ({getValue, row}) => {
let type = getValue();
if (type === 'text') {
type = (
<>
<TextBoxOutlineIcon
size={18}
color={'rgba(var(--center-channel-color-rgb), 0.64)'}
/>
<FormattedMessage
id='admin.system_properties.user_properties.table.type.text'
defaultMessage='Text'
/>
</>
);
}
return (
<TypeCellWrapper $deleted={row.original.delete_at !== 0}>
{type}
</TypeCellWrapper>
);
},
enableHiding: false,
enableSorting: false,
}),
col.display({
id: 'actions',
header: () => {
return (
<ColHeaderRight>
<FormattedMessage
id='admin.system_properties.user_properties.table.actions'
defaultMessage='Actions'
/>
</ColHeaderRight>
);
},
cell: ({row}) => (
<Actions
field={row.original}
updateField={updateField}
deleteField={deleteField}
/>
),
enableHiding: false,
enableSorting: false,
}),
];
}, [updateField, deleteField, collection.warnings]);
const table = useReactTable({
data,
columns,
initialState: {
sorting: [],
},
getCoreRowModel: getCoreRowModel<UserPropertyField>(),
getSortedRowModel: getSortedRowModel<UserPropertyField>(),
enableSortingRemoval: false,
enableMultiSort: false,
renderFallbackValue: '',
meta: {
tableId: 'userProperties',
disablePaginationControls: true,
},
manualPagination: true,
});
return (
<TableWrapper>
<AdminConsoleListTable<UserPropertyField> table={table}/>
</TableWrapper>
);
}
const TableWrapper = styled.div`
table.adminConsoleListTable {
td, th {
&:after, &:before {
display: none;
}
}
thead {
border-top: none;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
tr {
th.pinned {
background: rgba(var(--center-channel-color-rgb), 0.04);
padding-block-end: 8px;
padding-block-start: 8px;
}
}
}
tbody {
tr {
border-top: none;
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;
&:last-child {
padding-inline-end: 12px;
}
}
}
}
tfoot {
border-top: none;
}
}
.adminConsoleListTableContainer {
padding: 2px 0px;
}
`;
const Actions = ({field, deleteField}: {field: UserPropertyField} & FieldActions) => {
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;
`;
const ColHeaderRight = styled.div`
display: inline-block;
width: 100%;
text-align: right;
`;
const ActionsRoot = styled.div`
text-align: right;
`;
type EditableValueProps = {
value: string;
setValue: (value: string) => void;
autoFocus?: boolean;
disabled?: boolean;
deleted?: boolean;
footer?: ReactNode;
strong?: boolean;
maxLength?: number;
borderless?: boolean;
};
const EditableValue = (props: EditableValueProps) => {
const [value, setValue] = useState(props.value);
useEffect(() => {
setValue(props.value);
}, [props.value]);
return (
<>
<FieldInput
type='text'
data-testid='property-field-input'
disabled={props.disabled ?? props.deleted}
$deleted={props.deleted}
$strong={props.strong}
$borderless={props.borderless}
maxLength={props.maxLength}
autoFocus={props.autoFocus}
onFocus={(e) => {
if (props.autoFocus) {
e.target.select();
}
}}
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
onBlur={() => {
if (value !== props.value) {
props.setValue(value);
}
}}
/>
{props.footer}
</>
);
};

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

@@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act} from '@testing-library/react-hooks';
import type {UserPropertyField} from '@mattermost/types/properties';
import type {DeepPartial} from '@mattermost/types/utilities';
import {Client4} from 'mattermost-redux/client';
import {renderHookWithContext} from 'tests/react_testing_utils';
import {TestHelper} from 'utils/test_helper';
import type {GlobalState} from 'types/store';
import {} from './section_utils';
import {useUserPropertyFields} from './user_properties_utils';
function getBaseState(): DeepPartial<GlobalState> {
const currentUser = TestHelper.getUserMock();
const otherUser = TestHelper.getUserMock();
return {
entities: {
users: {
currentUserId: currentUser.id,
profiles: {
[currentUser.id]: currentUser,
[otherUser.id]: otherUser,
},
},
general: {
},
},
};
}
describe('useUserPropertyFields', () => {
jest.useFakeTimers();
const getCustomProfileAttributeFields = jest.spyOn(Client4, 'getCustomProfileAttributeFields');
it('should return a collection', async () => {
const field0: UserPropertyField = {id: 'f0', name: 'test attribute 0', type: 'text', create_at: 1736541716295, delete_at: 0, update_at: 0};
const field1: UserPropertyField = {id: 'f1', name: 'test attribute 1', type: 'text', create_at: 1736541716295, delete_at: 0, update_at: 0};
const field2: UserPropertyField = {id: 'f2', name: 'test attribute 2', type: 'text', create_at: 1736541716295, delete_at: 0, update_at: 0};
const field3: UserPropertyField = {id: 'f3', name: 'test attribute 3', type: 'text', create_at: 1736541716295, delete_at: 0, update_at: 0};
getCustomProfileAttributeFields.mockResolvedValue([field0, field1, field2, field3]);
const {result, rerender, waitFor} = renderHookWithContext(() => {
return useUserPropertyFields();
}, getBaseState());
const [fields1, read1] = result.current;
expect(read1.loading).toBe(true);
expect(read1.error).toBe(undefined);
expect(getCustomProfileAttributeFields).toBeCalledTimes(1);
expect(fields1.data).toEqual({});
expect(fields1.order).toEqual([]);
act(() => {
jest.runAllTimers();
});
rerender();
await waitFor(() => {
const [, read] = result.current;
expect(read.loading).toBe(false);
});
const [fields2, read2] = result.current;
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']);
});
});

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

@@ -0,0 +1,198 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import groupBy from 'lodash/groupBy';
import isEmpty from 'lodash/isEmpty';
import {useCallback, useMemo} from 'react';
import type {ClientError} from '@mattermost/client';
import {isStatusOK} from '@mattermost/types/client4';
import type {UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties';
import {collectionAddItem, collectionFromArray, collectionRemoveItem, collectionReplaceItem, collectionToArray} from '@mattermost/types/utilities';
import type {PartialExcept, IDMappedCollection} from '@mattermost/types/utilities';
import {Client4} from 'mattermost-redux/client';
import {generateId} from 'utils/utils';
import type {CollectionIO} from './section_utils';
import {useThing, usePendingThing, BatchProcessingError} from './section_utils';
export type UserPropertyFields = IDMappedCollection<UserPropertyField>;
export const useUserPropertyFields = () => {
// current fields
const [fieldCollection, readIO] = useThing<UserPropertyFields>(useMemo(() => ({
get: async () => {
const data = await Client4.getCustomProfileAttributeFields();
return collectionFromArray(data);
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
select: (state) => {
return undefined;
},
opts: {forceInitialGet: true},
}), []), collectionFromArray([]));
// save-sync operations
const commit = useCallback(async (collection: UserPropertyFields, prevCollection: UserPropertyFields) => {
const process = collectionToArray(collection).filter((field) => {
// process changed fields
return field !== prevCollection.data[field.id];
});
// prepare operations - create, delete, update
const fieldResults = await Promise.allSettled(process.map((item) => {
const {id, name, type} = item;
const patch: UserPropertyFieldPatch = {name, type};
if (isCreatePending(item)) {
return Client4.createCustomProfileAttributeField(patch);
} else if (isDeletePending(item)) {
return Client4.deleteCustomProfileAttributeField(id);
}
return Client4.patchCustomProfileAttributeField(id, patch);
}));
// process operation results
const processedCollection = fieldResults.reduce<UserPropertyFields>((results, op, i) => {
const preparedItem = process[i];
if (op.status === 'fulfilled') {
if (isStatusOK(op.value)) {
// process:data:deleted
Reflect.deleteProperty(results.data, preparedItem.id);
// process:order:deleted
results.order = results.order.filter((id) => id !== preparedItem.id);
} else {
const item = op.value;
// process:data:created, process:data:updated (set new data)
results.data[item?.id] = item;
if (item.id !== preparedItem.id) {
// process:order:deleted (delete old data)
Reflect.deleteProperty(results.data, preparedItem.id);
// process:order:created (replace pending id with created id)
results.order = results.order.map((id) => (id === preparedItem?.id ? item.id : id));
}
}
} else if (op.status === 'rejected') {
// failed, log error
results.errors = {...results.errors, [preparedItem.id]: op.reason};
}
return results;
}, {
data: {...collection.data},
order: [...collection.order],
errors: {}, // start with errors cleared; don't keep stale errors
});
if (isEmpty(processedCollection.errors)) {
Reflect.deleteProperty(processedCollection, 'errors');
} else {
// set pendingIO master error
throw new BatchProcessingError<ClientError>('error processing operations', {cause: processedCollection.errors});
}
return processedCollection;
}, []);
// pending fields to be saved
const [pendingCollection, pendingIO] = usePendingThing<UserPropertyFields, BatchProcessingError<ClientError>>(fieldCollection, {commit});
// edit pending fields before saving
const itemOps = useMemo(() => ({
update: (field) => {
pendingIO.apply((pending) => {
return validate(collectionReplaceItem(pending, field));
});
},
create: () => {
pendingIO.apply((pending) => {
const name = getIncrementedName('Text', pending);
const field = newPendingField({name, type: 'text'});
return collectionAddItem(pending, field);
});
},
delete: (id: string) => {
pendingIO.apply((pending) => {
const field = pending.data[id];
if (isCreatePending(field)) {
// immediately remove if deleting a field that is pending creation
return validate(collectionRemoveItem(pending, field));
}
return validate(collectionReplaceItem(pending, {...field, delete_at: Date.now()}));
});
},
} satisfies CollectionIO<UserPropertyField>), [pendingIO.apply]);
return [pendingCollection, readIO, pendingIO, itemOps] as const;
};
const validate = (pending: UserPropertyFields) => {
// Name
const byName = groupBy(pending.data, 'name');
const warnings = Object.values(pending.data).reduce<NonNullable<UserPropertyFields['warnings']>>((acc, field) => {
if (!field.name) {
acc[field.id] = {name: ValidationWarningNameRequired};
} else if (byName[field.name].length > 1) {
acc[field.id] = {name: ValidationWarningNameUnique};
}
return acc;
}, {});
const next = {...pending, warnings};
if (isEmpty(warnings)) {
Reflect.deleteProperty(next, 'warnings');
}
return next;
};
export const ValidationWarningNameRequired = 'user_properties.validation.name_required';
export const ValidationWarningNameUnique = 'user_properties.validation.name_unique';
const getIncrementedName = (desiredName: string, collection: UserPropertyFields) => {
const names = new Set(Object.values(collection.data).map(({name}) => name));
let newName = desiredName;
let n = 1;
while (names.has(newName)) {
n++;
newName = `${desiredName} ${n}`;
}
return newName;
};
const PENDING = 'pending_';
export const isCreatePending = <T extends {id: string; delete_at: number; create_at: number}>(item: T) => {
// has not been created and is not deleted
return item.create_at === 0 && item.delete_at === 0;
};
export const isDeletePending = <T extends {delete_at: number; create_at: number}>(item: T) => {
// has been created and needs to be deleted
return item.create_at !== 0 && item.delete_at !== 0;
};
export const newPendingId = () => `${PENDING}${generateId()}`;
export const newPendingField = (patch: PartialExcept<UserPropertyField, 'name'>): UserPropertyField => {
return {
...patch,
type: 'text',
id: newPendingId(),
create_at: 0,
delete_at: 0,
update_at: 0,
};
};

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

@@ -14,7 +14,7 @@ import Permissions from 'mattermost-redux/constants/permissions';
import type {ActionResult} from 'mattermost-redux/types/actions';
import BlockableLink from 'components/admin_console/blockable_link';
import SaveChangesPanel from 'components/admin_console/team_channel_settings/save_changes_panel';
import SaveChangesPanel from 'components/admin_console/save_changes_panel';
import FormError from 'components/form_error';
import AdminHeader from 'components/widgets/admin_console/admin_header';

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

@@ -32,11 +32,11 @@ import {ChannelModes} from './channel_modes';
import {ChannelProfile} from './channel_profile';
import type {ChannelModerationRoles} from './types';
import SaveChangesPanel from '../../../save_changes_panel';
import ConvertAndRemoveConfirmModal from '../../convert_and_remove_confirm_modal';
import ConvertConfirmModal from '../../convert_confirm_modal';
import {NeedGroupsError, UsersWillBeRemovedError} from '../../errors';
import RemoveConfirmModal from '../../remove_confirm_modal';
import SaveChangesPanel from '../../save_changes_panel';
export interface ChannelDetailsProps {
channelID: string;

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

@@ -26,9 +26,9 @@ import TeamMembers from './team_members/index';
import {TeamModes} from './team_modes';
import {TeamProfile} from './team_profile';
import SaveChangesPanel from '../../../save_changes_panel';
import {NeedDomainsError, NeedGroupsError, UsersWillBeRemovedError} from '../../errors';
import RemoveConfirmModal from '../../remove_confirm_modal';
import SaveChangesPanel from '../../save_changes_panel';
export type Props = {
teamID: string;

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

@@ -2479,6 +2479,7 @@
"admin.sidebar.siteStatistics": "Site Statistics",
"admin.sidebar.smtp": "SMTP",
"admin.sidebar.subscription": "Subscription",
"admin.sidebar.system_properties": "System Properties",
"admin.sidebar.systemRoles": "Delegated Granular Administration",
"admin.sidebar.teams": "Teams",
"admin.sidebar.teamStatistics": "Team Statistics",
@@ -2546,6 +2547,21 @@
"admin.support.termsOfServiceTextTitle": "Custom Terms of Service Text:",
"admin.support.termsOfServiceTitle": "Custom Terms of Service",
"admin.support.termsTitle": "Terms of Use Link:",
"admin.system_properties.confirm.delete.button": "Delete",
"admin.system_properties.confirm.delete.text": "Deleting this property will remove all user-defined values associated with it.",
"admin.system_properties.confirm.delete.title": "Delete {name} property",
"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.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.property": "Property",
"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_unique": "Property names must be unique.",
"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",
"admin.system_users_list.pagination": "Showing {firstPage} - {lastPage} of {totalItems} users",

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

@@ -469,6 +469,7 @@ export const ModalIdentifiers = {
SECURE_CONNECTION_ACCEPT_INVITE: 'secure_connection_accept_invite',
SHARED_CHANNEL_REMOTE_INVITE: 'shared_channel_remote_invite',
SHARED_CHANNEL_REMOTE_UNINVITE: 'shared_channel_remote_uninvite',
USER_PROPERTY_FIELD_DELETE: 'user_property_field_delete',
};
export const UserStatuses = {
@@ -2020,6 +2021,8 @@ export const Constants = {
MAX_CHANNELNAME_LENGTH: 64,
DEFAULT_CHANNELURL_SHORTEN_LENGTH: 52,
MAX_CHANNELPURPOSE_LENGTH: 250,
MAX_CUSTOM_ATTRIBUTE_NAME_LENGTH: 40,
MAX_CUSTOM_ATTRIBUTES: 20,
MAX_CUSTOM_ATTRIBUTE_LENGTH: 64,
MAX_FIRSTNAME_LENGTH: 64,
MAX_LASTNAME_LENGTH: 64,

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

@@ -31,6 +31,8 @@ export type StatusOK = {
status: 'OK';
};
export const isStatusOK = (x: StatusOK | Record<string, unknown>): x is StatusOK => (x as StatusOK)?.status === 'OK';
export type FetchPaginatedThreadOptions = {
fetchThreads?: boolean;
collapsedThreads?: boolean;

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

@@ -13,6 +13,13 @@ export type RelationOneToManyUnique<E1 extends {id: string}, E2 extends {id: str
export type IDMappedObjects<E extends {id: string}> = RelationOneToOne<E, E>;
export type IDMappedCollection<T extends {id: string}> = {
data: IDMappedObjects<T>;
order: Array<T['id']>;
errors?: RelationOneToOne<T, Error>;
warnings?: RelationOneToOne<T, {[Key in keyof T]?: string}>;
};
export type DeepPartial<T> = {
// For each field of T, make it optional and...
@@ -44,6 +51,10 @@ Pick<T, Exclude<keyof T, Keys>> & {[K in Keys]-?: Required<Pick<T, K>> & Partial
export type Intersection<T1, T2> =
Omit<Omit<T1&T2, keyof(Omit<T1, keyof(T2)>)>, keyof(Omit<T2, keyof(T1)>)>;
/** https://stackoverflow.com/a/66605669 */
type Only<T, U> = {[P in keyof T]: T[P]} & {[P in keyof U]?: never};
export type Either<T, U> = Only<T, U> | Only<U, T>;
export type PartialExcept<T extends Record<string, unknown>, TKeysNotPartial extends keyof T> = Partial<T> & Pick<T, TKeysNotPartial>;
export function isArrayOf<T>(v: unknown, check: (e: unknown) => boolean): v is T[] {
@@ -73,3 +84,30 @@ export function isRecordOf<T>(v: unknown, check: (e: unknown) => boolean): v is
return true;
}
export const collectionFromArray = <T extends {id: string}>(arr: T[] = []): IDMappedCollection<T> => {
return arr.reduce((current, item) => {
current.data = {...current.data, [item.id]: item};
current.order.push(item.id);
return current;
}, {data: {} as IDMappedObjects<T>, order: []} as IDMappedCollection<T>);
};
export const collectionToArray = <T extends {id: string}>({data, order}: IDMappedCollection<T>): T[] => {
return order.map((id) => data[id]);
};
export const collectionReplaceItem = <T extends {id: string}>(collection: IDMappedCollection<T>, item: T) => {
return {...collection, data: {...collection.data, [item.id]: item}};
};
export const collectionAddItem = <T extends {id: string}>(collection: IDMappedCollection<T>, item: T) => {
return {...collection, data: {...collection.data, [item.id]: item}, order: [...collection.order, item.id]};
};
export const collectionRemoveItem = <T extends {id: string}>(collection: IDMappedCollection<T>, item: T) => {
const data = {...collection.data};
Reflect.deleteProperty(data, item.id);
const order = collection.order.filter((id) => id !== item.id);
return {...collection, data, order};
};