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
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
e400e67732
Коммит
0085826203
@@ -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};
|
||||
};
|
||||
|
||||
Ссылка в новой задаче
Block a user