diff --git a/webapp/channels/src/components/admin_console/admin_console.tsx b/webapp/channels/src/components/admin_console/admin_console.tsx index a080dad058..68ac8146b8 100644 --- a/webapp/channels/src/components/admin_console/admin_console.tsx +++ b/webapp/channels/src/components/admin_console/admin_console.tsx @@ -1,7 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import type {Location} from 'history'; +import type {RefCallback} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {Route, Switch, Redirect} from 'react-router-dom'; import type {RouteComponentProps} from 'react-router-dom'; @@ -31,10 +33,6 @@ import type {PropsFromRedux} from './index'; export type Props = PropsFromRedux & RouteComponentProps; -type State = { - search: string; -} - // not every page in the system console will need the license and config, but the vast majority will type ExtraProps = { enterpriseReady: boolean; @@ -49,39 +47,72 @@ type ExtraProps = { isCurrentUserSystemAdmin: boolean; } -class AdminConsole extends React.PureComponent { - public constructor(props: Props) { - super(props); - this.state = { - search: '', - }; - } +/** + * Focus or scroll to a provided hash for the given {@link Location}. + * @returns a ref callback that should to be attached to an ancestor of the target hash element. + * @remarks emulates standard browser URL hash scroll-to behavior, but also works in custom or nested scroll containers. + */ +const useFocusScroller = (location: Location): RefCallback => { + const lastFocusedLocation = useRef(); - public componentDidMount(): void { - this.props.actions.getConfig(); - this.props.actions.getEnvironmentConfig(); - this.props.actions.loadRolesIfNeeded(['channel_user', 'team_user', 'system_user', 'channel_admin', 'team_admin', 'system_admin', 'system_user_manager', 'system_custom_group_admin', 'system_read_only_admin', 'system_manager']); - this.props.actions.selectLhsItem(LhsItemType.None); - this.props.actions.selectTeam(''); + return useCallback((node) => { + if (!node || !location.hash || lastFocusedLocation.current === location) { + // if there is no node or hash, or if we've already focused the hash for this location + return; + } + + const id = decodeURIComponent(location.hash.substring(1)); + + if (!id) { + return; + } + + const element = document.getElementById(id); + + if (!element) { + return; + } + + // focus the element, or scroll it into view if it couldn't be focused as a fallback + element.focus(); + if (document.activeElement !== element) { + element.scrollIntoView({behavior: 'auto'}); + } + + // only focus a hash for a given location once + lastFocusedLocation.current = location; + }, [location]); +}; + +const AdminConsole = (props: Props) => { + const [search, setSearch] = useState(''); + const handleFocusScroller = useFocusScroller(props.location); + + useEffect(() => { + props.actions.getConfig(); + props.actions.getEnvironmentConfig(); + props.actions.loadRolesIfNeeded(['channel_user', 'team_user', 'system_user', 'channel_admin', 'team_admin', 'system_admin', 'system_user_manager', 'system_custom_group_admin', 'system_read_only_admin', 'system_manager']); + props.actions.selectLhsItem(LhsItemType.None); + props.actions.selectTeam(''); document.body.classList.add('console__body'); document.getElementById('root')?.classList.add('console__root'); resetTheme(); - } - public componentWillUnmount(): void { - document.body.classList.remove('console__body'); - document.getElementById('root')?.classList.remove('console__root'); - applyTheme(this.props.currentTheme); + return () => { + document.body.classList.remove('console__body'); + document.getElementById('root')?.classList.remove('console__root'); + applyTheme(props.currentTheme); - // Reset the admin console users management table properties - this.props.actions.setAdminConsoleUsersManagementTableProperties(); - } + // Reset the admin console users management table properties + props.actions.setAdminConsoleUsersManagementTableProperties(); + }; + }, []); - private handleSearchChange = (search: string) => { - this.setState({search}); + const handleSearchChange = (searchTerm: string) => { + setSearch(searchTerm); }; - private mainRolesLoaded(roles: Record) { + const mainRolesLoaded = (roles: Record) => { return ( roles && roles.channel_admin && @@ -95,15 +126,15 @@ class AdminConsole extends React.PureComponent { roles.system_custom_group_admin && roles.system_manager ); - } + }; - private renderRoutes = (extraProps: ExtraProps) => { - const {adminDefinition, config, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin} = this.props; + const renderRoutes = (extraProps: ExtraProps) => { + const {adminDefinition, config, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin} = props; const schemas: AdminDefinitionSubSection[] = Object.values(adminDefinition).flatMap((section: AdminDefinitionSection) => { let isSectionHidden = false; if (typeof section.isHidden === 'function') { - isSectionHidden = section.isHidden(config, this.state, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin); + isSectionHidden = section.isHidden(config, {search}, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin); } else { isSectionHidden = Boolean(section.isHidden); } @@ -117,7 +148,7 @@ class AdminConsole extends React.PureComponent { const schemaRoutes = schemas.map((item: AdminDefinitionSubSection, index: number) => { if (typeof item.isHidden !== 'undefined') { - const isHidden = (typeof item.isHidden === 'function') ? item.isHidden(config, this.state, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin) : Boolean(item.isHidden); + const isHidden = (typeof item.isHidden === 'function') ? item.isHidden(config, {search}, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin) : Boolean(item.isHidden); if (isHidden) { return false; } @@ -126,7 +157,7 @@ class AdminConsole extends React.PureComponent { let isItemDisabled: boolean; if (typeof item.isDisabled === 'function') { - isItemDisabled = item.isDisabled(config, this.state, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin); + isItemDisabled = item.isDisabled(config, {search}, license, buildEnterpriseReady, consoleAccess, cloud, isCurrentUserSystemAdmin); } else { isItemDisabled = Boolean(item.isDisabled); } @@ -144,12 +175,12 @@ class AdminConsole extends React.PureComponent { return ( ( + path={`${props.match.url}/${item.url}`} + render={(routeProps) => ( @@ -161,80 +192,79 @@ class AdminConsole extends React.PureComponent { return ( {schemaRoutes} - {} + {} ); }; - public render(): JSX.Element | null { - const { - license, - config, - environmentConfig, - showNavigationPrompt, - roles, - } = this.props; - const {setNavigationBlocked, cancelNavigation, confirmNavigation, editRole, patchConfig} = this.props.actions; - - if (!this.props.currentUserHasAnAdminRole) { - return ( - - ); - } - - if (!this.mainRolesLoaded(this.props.roles)) { - return null; - } - - if (Object.keys(config).length === 0) { - return
; - } - - if (config && Object.keys(config).length === 0 && config.constructor === Object) { - return ( -
- ); - } - - const extraProps: ExtraProps = { - enterpriseReady: this.props.buildEnterpriseReady, - license, - config, - environmentConfig, - setNavigationBlocked, - roles, - editRole, - patchConfig, - cloud: this.props.cloud, - isCurrentUserSystemAdmin: this.props.isCurrentUserSystemAdmin, - }; + const { + license, + config, + environmentConfig, + showNavigationPrompt, + roles, + } = props; + const {setNavigationBlocked, cancelNavigation, confirmNavigation, editRole, patchConfig} = props.actions; + if (!props.currentUserHasAnAdminRole) { return ( - <> - - - - -
- - {this.renderRoutes(extraProps)} - -
- - - + ); } -} + + if (!mainRolesLoaded(props.roles)) { + return null; + } + + if (Object.keys(config).length === 0) { + return
; + } + + if (config && Object.keys(config).length === 0 && config.constructor === Object) { + return ( +
+ ); + } + + const extraProps: ExtraProps = { + enterpriseReady: props.buildEnterpriseReady, + license, + config, + environmentConfig, + setNavigationBlocked, + roles, + editRole, + patchConfig, + cloud: props.cloud, + isCurrentUserSystemAdmin: props.isCurrentUserSystemAdmin, + }; + + return ( + <> + + + + +
+ + {renderRoutes(extraProps)} + +
+ + + + ); +}; export default AdminConsole; diff --git a/webapp/channels/src/components/admin_console/admin_sidebar/admin_sidebar.tsx b/webapp/channels/src/components/admin_console/admin_sidebar/admin_sidebar.tsx index 3d4a8426cb..f59242d72c 100644 --- a/webapp/channels/src/components/admin_console/admin_sidebar/admin_sidebar.tsx +++ b/webapp/channels/src/components/admin_console/admin_sidebar/admin_sidebar.tsx @@ -79,7 +79,8 @@ class AdminSidebar extends React.PureComponent { this.props.actions.getPlugins(); } - if (this.searchRef.current) { + if (this.searchRef.current && !getHistory().location.hash) { + // default focus if no other target/hash is specified for auto-focus this.searchRef.current.focus(); } @@ -208,13 +209,11 @@ class AdminSidebar extends React.PureComponent { definitionKey={subDefinitionKey} name={item.url} restrictedIndicator={item.restrictedIndicator?.shouldDisplay(license, subscriptionProduct) ? item.restrictedIndicator.value(cloud) : undefined} - title={ - typeof item.title === 'string' ? - item.title : - - } + title={typeof item.title === 'string' ? item.title : ( + + )} /> )); }); @@ -237,13 +236,11 @@ class AdminSidebar extends React.PureComponent { parentLink='/admin_console' icon={section.icon} sectionClass='' - title={ - typeof section.sectionTitle === 'string' ? - section.sectionTitle : - - } + title={typeof section.sectionTitle === 'string' ? section.sectionTitle : ( + + )} > {sidebarItems} diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.scss b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.scss index 79dc81ae93..82521c8f0d 100644 --- a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.scss +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.scss @@ -30,3 +30,13 @@ font-weight: 600; } } + +.user-property-field-dotmenu__chip { + padding: 4px 12px; + border-radius: 12px; + background: rgba(var(--center-channel-color-rgb), 0.08); + color: var(--center-channel-color); + font-size: 12px; + font-style: normal; + font-weight: 600; +} diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx index bc1464f0a6..cf2dde0e35 100644 --- a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx @@ -110,7 +110,7 @@ describe('UserPropertyDotMenu', () => { }); }); - it('displays LDAP and SAML link menu options', async () => { + it('displays LDAP and SAML link menu options for existing fields', async () => { renderComponent(); // Open the menu @@ -120,8 +120,63 @@ describe('UserPropertyDotMenu', () => { // Verify both link options are shown expect(screen.getByText('Link property to AD/LDAP')).toBeInTheDocument(); expect(screen.getByText('Link property to SAML')).toBeInTheDocument(); + }); - // TODO mock history and verify the link actions + it('hides LDAP and SAML link menu options for pending fields', async () => { + const pendingField = { + ...baseField, + create_at: 0, // Mark as pending creation + }; + + renderComponent(pendingField); + + // Open the menu + const menuButton = screen.getByTestId(`user-property-field_dotmenu-${pendingField.id}`); + fireEvent.click(menuButton); + + // Verify both link options are not shown + expect(screen.queryByText('Link property to AD/LDAP')).not.toBeInTheDocument(); + expect(screen.queryByText('Link property to SAML')).not.toBeInTheDocument(); + }); + + it('shows "Edit link with" text when LDAP attribute is linked', async () => { + const linkedField = { + ...baseField, + attrs: { + ...baseField.attrs, + ldap: 'employeeID', + }, + }; + + renderComponent(linkedField); + + // Open the menu + const menuButton = screen.getByTestId(`user-property-field_dotmenu-${linkedField.id}`); + fireEvent.click(menuButton); + + // Verify the LDAP link text shows the linked property + expect(screen.getByText('Edit link with:')).toBeInTheDocument(); + expect(screen.getByText('AD/LDAP: employeeID')).toBeInTheDocument(); + }); + + it('shows "Edit link with" text when SAML attribute is linked', async () => { + const linkedField = { + ...baseField, + attrs: { + ...baseField.attrs, + saml: 'position', + }, + }; + + renderComponent(linkedField); + + // Open the menu + const menuButton = screen.getByTestId(`user-property-field_dotmenu-${linkedField.id}`); + fireEvent.click(menuButton); + + // Verify the SAML link text shows the linked property + expect(screen.getByText('Edit link with:')).toBeInTheDocument(); + expect(screen.getByText('SAML: position')).toBeInTheDocument(); }); it('handles field duplication', async () => { diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx index 19ea4aa34b..5c47705cb1 100644 --- a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {ComponentProps} from 'react'; import React from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; @@ -12,6 +13,7 @@ import * as Menu from 'components/menu'; import './user_properties_dot_menu.scss'; import {useUserPropertyFieldDelete} from './user_properties_delete_modal'; import {isCreatePending} from './user_properties_utils'; + type Props = { field: UserPropertyField; canCreate: boolean; @@ -174,28 +176,50 @@ const DotMenu = ({ )} /> - } - labels={( - - )} - /> - } - labels={( - - )} - /> + {field.create_at !== 0 && ([ + } + labels={field.attrs.ldap ? ( + AD/LDAP: {propertyName}'} + values={{ + Chip: (chunks: React.ReactNode) => {chunks}, + propertyName: field.attrs.ldap, + }} + /> + ) : ( + + )} + />, + } + labels={field.attrs.saml ? ( + SAML: {propertyName}'} + values={{ + Chip: (chunks: React.ReactNode) => {chunks}, + propertyName: field.attrs.saml, + }} + /> + ) : ( + + )} + />, + ])} {canCreate && ( ) => ( + + {children} + +); + export default DotMenu; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 9e43e56939..12fb9ee252 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -2729,10 +2729,12 @@ "admin.system_properties.details.saving_changes": "Saving configuration…", "admin.system_properties.details.saving_changes_error": "There was an error while saving the configuration", "admin.system_properties.user_properties.add_property": "Add property", + "admin.system_properties.user_properties.dotmenu.ad_ldap.edit_link.label": "Edit link with: AD/LDAP: {propertyName}", "admin.system_properties.user_properties.dotmenu.ad_ldap.link_property.label": "Link property to AD/LDAP", "admin.system_properties.user_properties.dotmenu.delete.label": "Delete property", "admin.system_properties.user_properties.dotmenu.duplicate.label": "Duplicate property", "admin.system_properties.user_properties.dotmenu.duplicate.name_copy": "{fieldName} (copy)", + "admin.system_properties.user_properties.dotmenu.saml.edit_link.label": "Edit link with: SAML: {propertyName}", "admin.system_properties.user_properties.dotmenu.saml.link_property.label": "Link property to SAML", "admin.system_properties.user_properties.dotmenu.visibility.always.label": "Always show", "admin.system_properties.user_properties.dotmenu.visibility.hidden.label": "Always hide", diff --git a/webapp/channels/src/sass/routes/_admin-console.scss b/webapp/channels/src/sass/routes/_admin-console.scss index c6c6a71cb6..a290dbabb8 100644 --- a/webapp/channels/src/sass/routes/_admin-console.scss +++ b/webapp/channels/src/sass/routes/_admin-console.scss @@ -13,6 +13,7 @@ height: 100%; color: functions.v(center-channel-color); grid-area: center; + scroll-behavior: smooth; > div { width: 100%;