diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx
index 85cf579652..3757c19590 100644
--- a/webapp/channels/src/components/admin_console/admin_definition.tsx
+++ b/webapp/channels/src/components/admin_console/admin_definition.tsx
@@ -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'}),
diff --git a/webapp/channels/src/components/admin_console/blockable_button/blockable_button.tsx b/webapp/channels/src/components/admin_console/blockable_button/blockable_button.tsx
new file mode 100644
index 0000000000..229cc0a3f5
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/blockable_button/blockable_button.tsx
@@ -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 (
+
+ );
+};
+
+export default React.memo(BlockableButton);
diff --git a/webapp/channels/src/components/admin_console/blockable_button/index.ts b/webapp/channels/src/components/admin_console/blockable_button/index.ts
new file mode 100644
index 0000000000..2a02851efe
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/blockable_button/index.ts
@@ -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);
diff --git a/webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx b/webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx
index 8b00e2ad80..ffe4a08c6d 100644
--- a/webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx
+++ b/webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx
@@ -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';
diff --git a/webapp/channels/src/components/admin_console/ip_filtering/index.tsx b/webapp/channels/src/components/admin_console/ip_filtering/index.tsx
index 9defac2723..34d2b1e55b 100644
--- a/webapp/channels/src/components/admin_console/ip_filtering/index.tsx
+++ b/webapp/channels/src/components/admin_console/ip_filtering/index.tsx
@@ -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';
diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/save_changes_panel.tsx b/webapp/channels/src/components/admin_console/save_changes_panel.tsx
similarity index 50%
rename from webapp/channels/src/components/admin_console/team_channel_settings/save_changes_panel.tsx
rename to webapp/channels/src/components/admin_console/save_changes_panel.tsx
index 6e92e02393..ad10191160 100644
--- a/webapp/channels/src/components/admin_console/team_channel_settings/save_changes_panel.tsx
+++ b/webapp/channels/src/components/admin_console/save_changes_panel.tsx
@@ -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 (
@@ -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 !== '' &&
-
-
-
- }
+ {cancelLink ? (
+
+
+
+ ) : onCancel && (
+
+
+
+ )}
{serverError}
diff --git a/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.tsx b/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.tsx
index 84407fbb9b..a8098eaed9 100644
--- a/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.tsx
+++ b/webapp/channels/src/components/admin_console/secure_connections/secure_connection_detail.tsx
@@ -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'];
diff --git a/webapp/channels/src/components/admin_console/system_properties/controls.tsx b/webapp/channels/src/components/admin_console/system_properties/controls.tsx
new file mode 100644
index 0000000000..9d9781220d
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/system_properties/controls.tsx
@@ -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 (
+
+ );
+};
+
+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;
+`;
diff --git a/webapp/channels/src/components/admin_console/system_properties/index.ts b/webapp/channels/src/components/admin_console/system_properties/index.ts
new file mode 100644
index 0000000000..68c69e6092
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/system_properties/index.ts
@@ -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;
diff --git a/webapp/channels/src/components/admin_console/system_properties/section_utils.test.ts b/webapp/channels/src/components/admin_console/system_properties/section_utils.test.ts
new file mode 100644
index 0000000000..b9e24753b1
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/system_properties/section_utils.test.ts
@@ -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
{
+ 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;
+ 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;
+ 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();
+ });
+});
diff --git a/webapp/channels/src/components/admin_console/system_properties/section_utils.ts b/webapp/channels/src/components/admin_console/system_properties/section_utils.ts
new file mode 100644
index 0000000000..e33aced9f2
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/system_properties/section_utils.ts
@@ -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 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 = boolean | TError;
+
+const status = (state: TLoadingState) => {
+ 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 = (initialState: TLoadingState) => {
+ const [state, setState] = useState>(initialState);
+ return [status(state), setState] as const;
+};
+
+export type ReadOperations = {
+ get: () => Promise;
+ select?: (state: GlobalState) => T | undefined;
+ opts?: {forceInitialGet: boolean; initial?: Partial};
+}
+
+export interface CollectionIO {
+ create?: (patch?: Partial) => 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(op: (...args: TArgs) => T | undefined | Promise, initialStatus = true) {
+ const [status, setStatus] = useOperationStatus(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(ops: ReadOperations, initial: T) {
+ const forceInitialGet = ops.opts?.forceInitialGet ?? true;
+ const selected = useSelector((state) => ops.select?.(state));
+ const [data, setData] = useState(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, TErr extends Error>(data: T, opts: {commit: (pending: T, current: T) => T | Promise}) {
+ const [pending, setPending] = useState(data);
+ const hasChanges = pending !== data;
+
+ const [doCommit, {loading: saving, error}, setStatus] = useOperation, 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;
+}
diff --git a/webapp/channels/src/components/admin_console/system_properties/system_properties.tsx b/webapp/channels/src/components/admin_console/system_properties/system_properties.tsx
new file mode 100644
index 0000000000..c6a0df6f53
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/system_properties/system_properties.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {userProperties.content}
+
+
+
+
+ ) : undefined}
+ savingMessage={formatMessage({id: 'admin.system_properties.details.saving_changes', defaultMessage: 'Saving configuration…'})}
+ isDisabled={props.disabled || saving || !isValid}
+ />
+
+ );
+}
+
+const msg = defineMessages({
+ pageTitle: {id: 'admin.sidebar.system_properties', defaultMessage: 'System Properties'},
+});
+
+export const searchableStrings: SearchableStrings = Object.values(msg);
diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_delete_modal.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_delete_modal.tsx
new file mode 100644
index 0000000000..37e78edafb
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_delete_modal.tsx
@@ -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((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 = (
+
+ );
+
+ return (
+
+ {message}
+
+ );
+}
+
+export default RemoveUserPropertyFieldModal;
diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_table.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_table.tsx
new file mode 100644
index 0000000000..d94aea4bcf
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_table.tsx
@@ -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 ? (
+
+ ) : (
+ <>
+
+ {userPropertyFields.order.length < Constants.MAX_CUSTOM_ATTRIBUTES && (
+
+
+
+
+ )}
+ >
+ );
+
+ 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();
+ const columns = useMemo>>(() => {
+ return [
+ col.accessor('name', {
+ header: () => {
+ return (
+
+
+
+ );
+ },
+ cell: ({getValue, row}) => {
+ const toDelete = row.original.delete_at !== 0;
+ const warningId = collection.warnings?.[row.original.id]?.name;
+
+ let warning;
+
+ if (warningId === ValidationWarningNameRequired) {
+ warning = (
+
+ );
+ } else if (warningId === ValidationWarningNameUnique) {
+ warning = (
+
+ );
+ }
+
+ return (
+ <>
+ {
+ updateField({...row.original, name: value.trim()});
+ }}
+ maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_NAME_LENGTH}
+ />
+ {!toDelete && warning}
+ >
+ );
+ },
+ enableHiding: false,
+ enableSorting: false,
+ }),
+ col.accessor('type', {
+ header: () => {
+ return (
+
+
+
+ );
+ },
+ cell: ({getValue, row}) => {
+ let type = getValue();
+
+ if (type === 'text') {
+ type = (
+ <>
+
+
+ >
+ );
+ }
+
+ return (
+
+ {type}
+
+ );
+ },
+ enableHiding: false,
+ enableSorting: false,
+ }),
+ col.display({
+ id: 'actions',
+ header: () => {
+ return (
+
+
+
+ );
+ },
+ cell: ({row}) => (
+
+ ),
+ enableHiding: false,
+ enableSorting: false,
+ }),
+ ];
+ }, [updateField, deleteField, collection.warnings]);
+
+ const table = useReactTable({
+ data,
+ columns,
+ initialState: {
+ sorting: [],
+ },
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ enableSortingRemoval: false,
+ enableMultiSort: false,
+ renderFallbackValue: '',
+ meta: {
+ tableId: 'userProperties',
+ disablePaginationControls: true,
+ },
+ manualPagination: true,
+ });
+
+ return (
+
+ table={table}/>
+
+ );
+}
+
+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 (
+
+ {field.delete_at === 0 && (
+
+
+
+ )}
+
+ );
+};
+
+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 (
+ <>
+ {
+ if (props.autoFocus) {
+ e.target.select();
+ }
+ }}
+ value={value}
+ onChange={(e) => {
+ setValue(e.target.value);
+ }}
+ onBlur={() => {
+ if (value !== props.value) {
+ props.setValue(value);
+ }
+ }}
+ />
+ {props.footer}
+ >
+ );
+};
diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.test.ts b/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.test.ts
new file mode 100644
index 0000000000..625b898437
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.test.ts
@@ -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 {
+ 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']);
+ });
+});
diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.ts b/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.ts
new file mode 100644
index 0000000000..f8c88b3be4
--- /dev/null
+++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_utils.ts
@@ -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;
+
+export const useUserPropertyFields = () => {
+ // current fields
+ const [fieldCollection, readIO] = useThing(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((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('error processing operations', {cause: processedCollection.errors});
+ }
+
+ return processedCollection;
+ }, []);
+
+ // pending fields to be saved
+ const [pendingCollection, pendingIO] = usePendingThing>(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), [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>((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 = (item: T) => {
+ // has not been created and is not deleted
+ return item.create_at === 0 && item.delete_at === 0;
+};
+
+export const isDeletePending = (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 => {
+ return {
+ ...patch,
+ type: 'text',
+ id: newPendingId(),
+ create_at: 0,
+ delete_at: 0,
+ update_at: 0,
+ };
+};
diff --git a/webapp/channels/src/components/admin_console/system_roles/system_role/system_role.tsx b/webapp/channels/src/components/admin_console/system_roles/system_role/system_role.tsx
index 9233de637c..b8e4f93fd7 100644
--- a/webapp/channels/src/components/admin_console/system_roles/system_role/system_role.tsx
+++ b/webapp/channels/src/components/admin_console/system_roles/system_role/system_role.tsx
@@ -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';
diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_details.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_details.tsx
index dfdf802af0..395ac3951f 100644
--- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_details.tsx
+++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_details.tsx
@@ -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;
diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/team/details/team_details.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/team/details/team_details.tsx
index 057640bc05..a5d068ebce 100644
--- a/webapp/channels/src/components/admin_console/team_channel_settings/team/details/team_details.tsx
+++ b/webapp/channels/src/components/admin_console/team_channel_settings/team/details/team_details.tsx
@@ -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;
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json
index b24f2092d1..eb60bddd70 100644
--- a/webapp/channels/src/i18n/en.json
+++ b/webapp/channels/src/i18n/en.json
@@ -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",
diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx
index 63af904fd8..fccb34fc69 100644
--- a/webapp/channels/src/utils/constants.tsx
+++ b/webapp/channels/src/utils/constants.tsx
@@ -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,
diff --git a/webapp/platform/types/src/client4.ts b/webapp/platform/types/src/client4.ts
index b4d8704608..d3453c9c39 100644
--- a/webapp/platform/types/src/client4.ts
+++ b/webapp/platform/types/src/client4.ts
@@ -31,6 +31,8 @@ export type StatusOK = {
status: 'OK';
};
+export const isStatusOK = (x: StatusOK | Record): x is StatusOK => (x as StatusOK)?.status === 'OK';
+
export type FetchPaginatedThreadOptions = {
fetchThreads?: boolean;
collapsedThreads?: boolean;
diff --git a/webapp/platform/types/src/utilities.ts b/webapp/platform/types/src/utilities.ts
index 2e8a7faa5a..1531e1246c 100644
--- a/webapp/platform/types/src/utilities.ts
+++ b/webapp/platform/types/src/utilities.ts
@@ -13,6 +13,13 @@ export type RelationOneToManyUnique = RelationOneToOne;
+export type IDMappedCollection = {
+ data: IDMappedObjects;
+ order: Array;
+ errors?: RelationOneToOne;
+ warnings?: RelationOneToOne;
+};
+
export type DeepPartial = {
// For each field of T, make it optional and...
@@ -44,6 +51,10 @@ Pick> & {[K in Keys]-?: Required> & Partial
export type Intersection =
Omit)>, keyof(Omit)>;
+/** https://stackoverflow.com/a/66605669 */
+type Only = {[P in keyof T]: T[P]} & {[P in keyof U]?: never};
+export type Either = Only | Only;
+
export type PartialExcept, TKeysNotPartial extends keyof T> = Partial & Pick;
export function isArrayOf(v: unknown, check: (e: unknown) => boolean): v is T[] {
@@ -73,3 +84,30 @@ export function isRecordOf(v: unknown, check: (e: unknown) => boolean): v is
return true;
}
+
+export const collectionFromArray = (arr: T[] = []): IDMappedCollection => {
+ return arr.reduce((current, item) => {
+ current.data = {...current.data, [item.id]: item};
+ current.order.push(item.id);
+ return current;
+ }, {data: {} as IDMappedObjects, order: []} as IDMappedCollection);
+};
+
+export const collectionToArray = ({data, order}: IDMappedCollection): T[] => {
+ return order.map((id) => data[id]);
+};
+
+export const collectionReplaceItem = (collection: IDMappedCollection, item: T) => {
+ return {...collection, data: {...collection.data, [item.id]: item}};
+};
+
+export const collectionAddItem = (collection: IDMappedCollection, item: T) => {
+ return {...collection, data: {...collection.data, [item.id]: item}, order: [...collection.order, item.id]};
+};
+
+export const collectionRemoveItem = (collection: IDMappedCollection, item: T) => {
+ const data = {...collection.data};
+ Reflect.deleteProperty(data, item.id);
+ const order = collection.order.filter((id) => id !== item.id);
+ return {...collection, data, order};
+};