Role picker: Refactor menu components (#60584)

* Simplify underlying components

* Move state management deeper to RoleMenuGroupsSection component

* Get rid of some unnecessary props passing

* Reduce number of unnecessary re-renders

* Simplify state
This commit is contained in:
Alexander Zobnin 2022-12-21 11:59:12 +03:00 committed by GitHub
parent 3d8890453f
commit 5ef545d290
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 140 additions and 163 deletions

View File

@ -1,18 +1,20 @@
import { cx } from '@emotion/css'; import { cx } from '@emotion/css';
import React, { FormEvent } from 'react'; import React, { FormEvent, memo } from 'react';
import { SelectableValue } from '@grafana/data';
import { Checkbox, Portal, useStyles2, useTheme2 } from '@grafana/ui'; import { Checkbox, Portal, useStyles2, useTheme2 } from '@grafana/ui';
import { getSelectStyles } from '@grafana/ui/src/components/Select/getSelectStyles'; import { getSelectStyles } from '@grafana/ui/src/components/Select/getSelectStyles';
import { getStyles } from './styles'; import { getStyles } from './styles';
interface RoleMenuGroupsOptionProps { interface RoleMenuGroupsOptionProps {
data: SelectableValue<string>; // display name
name: string;
// group id
value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
onClick?: (value: string) => void; onClick?: (value: string) => void;
onOpenSubMenu?: (value: string) => void; onOpenSubMenu?: (value: string) => void;
onCloseSubMenu?: (value: string) => void; onCloseSubMenu?: () => void;
isSelected?: boolean; isSelected?: boolean;
partiallySelected?: boolean; partiallySelected?: boolean;
isFocused?: boolean; isFocused?: boolean;
@ -21,84 +23,87 @@ interface RoleMenuGroupsOptionProps {
root?: HTMLElement; root?: HTMLElement;
} }
export const RoleMenuGroupOption = React.forwardRef<HTMLDivElement, RoleMenuGroupsOptionProps>( export const RoleMenuGroupOption = memo(
( React.forwardRef<HTMLDivElement, RoleMenuGroupsOptionProps>(
{ (
data, {
isFocused, name,
isSelected, value,
partiallySelected, isFocused,
disabled, isSelected,
onChange, partiallySelected,
onClick, disabled,
onOpenSubMenu, onChange,
onCloseSubMenu, onClick,
children, onOpenSubMenu,
root, onCloseSubMenu,
}, children,
ref root,
) => { },
const theme = useTheme2(); ref
const styles = getSelectStyles(theme); ) => {
const customStyles = useStyles2(getStyles); const theme = useTheme2();
const styles = getSelectStyles(theme);
const customStyles = useStyles2(getStyles);
const wrapperClassName = cx( const wrapperClassName = cx(
styles.option, styles.option,
isFocused && styles.optionFocused, isFocused && styles.optionFocused,
disabled && customStyles.menuOptionDisabled disabled && customStyles.menuOptionDisabled
); );
const onChangeInternal = (event: FormEvent<HTMLElement>) => { const onChangeInternal = (event: FormEvent<HTMLElement>) => {
if (disabled) { if (disabled) {
return; return;
} }
if (data.value) { if (value) {
onChange(data.value); onChange(value);
} }
}; };
const onClickInternal = (event: FormEvent<HTMLElement>) => { const onClickInternal = (event: FormEvent<HTMLElement>) => {
if (onClick) { if (onClick) {
onClick(data.value!); onClick(value!);
} }
}; };
const onMouseEnter = () => { const onMouseEnter = () => {
if (onOpenSubMenu) { if (onOpenSubMenu) {
onOpenSubMenu(data.value!); onOpenSubMenu(value!);
} }
}; };
const onMouseLeave = () => { const onMouseLeave = () => {
if (onCloseSubMenu) { if (onCloseSubMenu) {
onCloseSubMenu(data.value!); onCloseSubMenu();
} }
}; };
return ( return (
<div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}> <div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<div ref={ref} className={wrapperClassName} aria-label="Role picker option" onClick={onClickInternal}> <div ref={ref} className={wrapperClassName} aria-label="Role picker option" onClick={onClickInternal}>
<Checkbox <Checkbox
value={isSelected} value={isSelected}
className={cx(customStyles.menuOptionCheckbox, { className={cx(customStyles.menuOptionCheckbox, {
[customStyles.checkboxPartiallyChecked]: partiallySelected, [customStyles.checkboxPartiallyChecked]: partiallySelected,
})} })}
onChange={onChangeInternal} onChange={onChangeInternal}
disabled={disabled} disabled={disabled}
/> />
<div className={cx(styles.optionBody, customStyles.menuOptionBody)}> <div className={cx(styles.optionBody, customStyles.menuOptionBody)}>
<span>{data.displayName || data.name}</span> <span>{name}</span>
<span className={customStyles.menuOptionExpand} /> <span className={customStyles.menuOptionExpand} />
</div>
{root && children && (
<Portal className={customStyles.subMenuPortal} root={root}>
{children}
</Portal>
)}
</div> </div>
{root && children && (
<Portal className={customStyles.subMenuPortal} root={root}>
{children}
</Portal>
)}
</div> </div>
</div> );
); }
} )
); );
RoleMenuGroupOption.displayName = 'RoleMenuGroupOption'; RoleMenuGroupOption.displayName = 'RoleMenuGroupOption';

View File

@ -1,37 +1,31 @@
import React from 'react'; import React, { useCallback, useState } from 'react';
import { useStyles2, getSelectStyles, useTheme2 } from '@grafana/ui';
import { Role } from 'app/types'; import { Role } from 'app/types';
import { RoleMenuGroupOption } from './RoleMenuGroupOption'; import { RoleMenuGroupOption } from './RoleMenuGroupOption';
import { RoleMenuOption } from './RoleMenuOption'; import { RoleMenuOption } from './RoleMenuOption';
import { RolePickerSubMenu } from './RolePickerSubMenu'; import { RolePickerSubMenu } from './RolePickerSubMenu';
import { getStyles } from './styles';
import { isNotDelegatable } from './utils'; import { isNotDelegatable } from './utils';
interface RoleMenuGroupsSectionProps { interface RoleMenuGroupsSectionProps {
roles: Role[]; roles: Role[];
renderedName: string; renderedName: string;
menuSectionStyle: string;
groupHeaderStyle: string;
optionBodyStyle: string;
showGroups?: boolean; showGroups?: boolean;
optionGroups: Array<{ optionGroups: Array<{
name: string; name: string;
options: Role[]; options: Role[];
value: string; value: string;
}>; }>;
onChange: (value: string) => void; onGroupChange: (value: string) => void;
onOpenSubMenuRMGS: (value: string) => void;
onCloseSubMenu?: (value: string) => void;
groupSelected: (group: string) => boolean; groupSelected: (group: string) => boolean;
groupPartiallySelected: (group: string) => boolean; groupPartiallySelected: (group: string) => boolean;
disabled?: boolean; disabled?: boolean;
subMenuNode?: HTMLDivElement; subMenuNode?: HTMLDivElement;
showSubMenu: boolean;
openedMenuGroup: string;
subMenuOptions: Role[];
selectedOptions: Role[]; selectedOptions: Role[];
onChangeSubMenu: (option: Role) => void; onRoleChange: (option: Role) => void;
onClearSubMenu: () => void; onClearSubMenu: (group: string) => void;
showOnLeftSubMenu: boolean; showOnLeftSubMenu: boolean;
} }
@ -40,53 +34,63 @@ export const RoleMenuGroupsSection = React.forwardRef<HTMLDivElement, RoleMenuGr
{ {
roles, roles,
renderedName, renderedName,
menuSectionStyle,
groupHeaderStyle,
optionBodyStyle,
showGroups, showGroups,
optionGroups, optionGroups,
onChange, onGroupChange,
groupSelected, groupSelected,
groupPartiallySelected, groupPartiallySelected,
onOpenSubMenuRMGS,
onCloseSubMenu,
subMenuNode, subMenuNode,
showSubMenu,
openedMenuGroup,
subMenuOptions,
selectedOptions, selectedOptions,
onChangeSubMenu, onRoleChange,
onClearSubMenu, onClearSubMenu,
showOnLeftSubMenu, showOnLeftSubMenu,
}, },
_ref _ref
) => { ) => {
const [showSubMenu, setShowSubMenu] = useState(false);
const [openedMenuGroup, setOpenedMenuGroup] = useState('');
const theme = useTheme2();
const selectStyles = getSelectStyles(theme);
const styles = useStyles2(getStyles);
const onOpenSubMenu = useCallback((value: string) => {
setOpenedMenuGroup(value);
setShowSubMenu(true);
}, []);
const onCloseSubMenu = useCallback(() => {
setShowSubMenu(false);
setOpenedMenuGroup('');
}, []);
return ( return (
<div> <div>
{roles.length > 0 && ( {roles.length > 0 && (
<div className={menuSectionStyle}> <div className={styles.menuSection}>
<div className={groupHeaderStyle}>{renderedName}</div> <div className={styles.groupHeader}>{renderedName}</div>
<div className={optionBodyStyle}></div> <div className={selectStyles.optionBody}></div>
{showGroups && !!optionGroups?.length {showGroups && !!optionGroups?.length
? optionGroups.map((groupOption) => ( ? optionGroups.map((groupOption) => (
<RoleMenuGroupOption <RoleMenuGroupOption
data={groupOption}
key={groupOption.value} key={groupOption.value}
name={groupOption.name}
value={groupOption.value}
isSelected={groupSelected(groupOption.value) || groupPartiallySelected(groupOption.value)} isSelected={groupSelected(groupOption.value) || groupPartiallySelected(groupOption.value)}
partiallySelected={groupPartiallySelected(groupOption.value)} partiallySelected={groupPartiallySelected(groupOption.value)}
disabled={groupOption.options?.every(isNotDelegatable)} disabled={groupOption.options?.every(isNotDelegatable)}
onChange={onChange} onChange={onGroupChange}
onOpenSubMenu={onOpenSubMenuRMGS} onOpenSubMenu={onOpenSubMenu}
onCloseSubMenu={onCloseSubMenu} onCloseSubMenu={onCloseSubMenu}
root={subMenuNode} root={subMenuNode}
isFocused={showSubMenu && openedMenuGroup === groupOption.value} isFocused={showSubMenu && openedMenuGroup === groupOption.value}
> >
{showSubMenu && openedMenuGroup === groupOption.value && ( {showSubMenu && openedMenuGroup === groupOption.value && (
<RolePickerSubMenu <RolePickerSubMenu
options={subMenuOptions} options={groupOption.options}
selectedOptions={selectedOptions} selectedOptions={selectedOptions}
onSelect={onChangeSubMenu} onSelect={onRoleChange}
onClear={onClearSubMenu} onClear={() => onClearSubMenu(openedMenuGroup)}
showOnLeft={showOnLeftSubMenu} showOnLeft={showOnLeftSubMenu}
/> />
)} )}
@ -98,7 +102,7 @@ export const RoleMenuGroupsSection = React.forwardRef<HTMLDivElement, RoleMenuGr
key={option.uid} key={option.uid}
isSelected={!!(option.uid && !!selectedOptions.find((opt) => opt.uid === option.uid))} isSelected={!!(option.uid && !!selectedOptions.find((opt) => opt.uid === option.uid))}
disabled={isNotDelegatable(option)} disabled={isNotDelegatable(option)}
onChange={onChangeSubMenu} onChange={onRoleChange}
hideDescription hideDescription
/> />
))} ))}

View File

@ -71,10 +71,6 @@ export const RolePickerMenu = ({
}: RolePickerMenuProps): JSX.Element => { }: RolePickerMenuProps): JSX.Element => {
const [selectedOptions, setSelectedOptions] = useState<Role[]>(appliedRoles); const [selectedOptions, setSelectedOptions] = useState<Role[]>(appliedRoles);
const [selectedBuiltInRole, setSelectedBuiltInRole] = useState<OrgRole | undefined>(basicRole); const [selectedBuiltInRole, setSelectedBuiltInRole] = useState<OrgRole | undefined>(basicRole);
const [showSubMenu, setShowSubMenu] = useState(false);
const [openedMenuGroup, setOpenedMenuGroup] = useState('');
const [subMenuOptions, setSubMenuOptions] = useState<Role[]>([]);
const [optionGroups, setOptionGroups] = useState<{ [key: string]: RoleGroupOption[] }>({});
const [rolesCollection, setRolesCollection] = useState<{ [key: string]: RolesCollectionEntry }>({}); const [rolesCollection, setRolesCollection] = useState<{ [key: string]: RolesCollectionEntry }>({});
const subMenuNode = useRef<HTMLDivElement | null>(null); const subMenuNode = useRef<HTMLDivElement | null>(null);
const theme = useTheme2(); const theme = useTheme2();
@ -92,7 +88,7 @@ export const RolePickerMenu = ({
} }
}, [selectedBuiltInRole, onBasicRoleSelect]); }, [selectedBuiltInRole, onBasicRoleSelect]);
// Evaluate optionGroups and rolesCollection only if options changed, otherwise // Evaluate rolesCollection only if options changed, otherwise
// it triggers unnecessary re-rendering of <RoleMenuGroupsSection /> component // it triggers unnecessary re-rendering of <RoleMenuGroupsSection /> component
useEffect(() => { useEffect(() => {
const customRoles = options.filter(filterCustomRoles).sort(sortRolesByName); const customRoles = options.filter(filterCustomRoles).sort(sortRolesByName);
@ -103,7 +99,6 @@ export const RolePickerMenu = ({
custom: convertRolesToGroupOptions(customRoles).sort((a, b) => a.name.localeCompare(b.name)), custom: convertRolesToGroupOptions(customRoles).sort((a, b) => a.name.localeCompare(b.name)),
plugin: convertRolesToGroupOptions(pluginRoles).sort((a, b) => a.name.localeCompare(b.name)), plugin: convertRolesToGroupOptions(pluginRoles).sort((a, b) => a.name.localeCompare(b.name)),
}; };
setOptionGroups(optionGroups);
setRolesCollection({ setRolesCollection({
fixed: { fixed: {
@ -118,7 +113,7 @@ export const RolePickerMenu = ({
renderedName: `Custom roles`, renderedName: `Custom roles`,
roles: customRoles, roles: customRoles,
}, },
pluginRoles: { plugin: {
groupType: GroupType.plugin, groupType: GroupType.plugin,
optionGroup: optionGroups.plugin, optionGroup: optionGroups.plugin,
renderedName: `Plugin roles`, renderedName: `Plugin roles`,
@ -139,13 +134,13 @@ export const RolePickerMenu = ({
const groupSelected = (groupType: GroupType, group: string) => { const groupSelected = (groupType: GroupType, group: string) => {
const selectedGroupOptions = getSelectedGroupOptions(group); const selectedGroupOptions = getSelectedGroupOptions(group);
const groupOptions = optionGroups[groupType].find((g) => g.value === group); const groupOptions = rolesCollection[groupType]?.optionGroup.find((g) => g.value === group);
return selectedGroupOptions.length > 0 && selectedGroupOptions.length >= groupOptions!.options.length; return selectedGroupOptions.length > 0 && selectedGroupOptions.length >= groupOptions!.options.length;
}; };
const groupPartiallySelected = (groupType: GroupType, group: string) => { const groupPartiallySelected = (groupType: GroupType, group: string) => {
const selectedGroupOptions = getSelectedGroupOptions(group); const selectedGroupOptions = getSelectedGroupOptions(group);
const groupOptions = optionGroups[groupType].find((g) => g.value === group); const groupOptions = rolesCollection[groupType]?.optionGroup.find((g) => g.value === group);
return selectedGroupOptions.length > 0 && selectedGroupOptions.length < groupOptions!.options.length; return selectedGroupOptions.length > 0 && selectedGroupOptions.length < groupOptions!.options.length;
}; };
@ -158,7 +153,7 @@ export const RolePickerMenu = ({
}; };
const onGroupChange = (groupType: GroupType, value: string) => { const onGroupChange = (groupType: GroupType, value: string) => {
const group = optionGroups[groupType].find((g) => { const group = rolesCollection[groupType]?.optionGroup.find((g) => {
return g.value === value; return g.value === value;
}); });
@ -175,23 +170,6 @@ export const RolePickerMenu = ({
} }
}; };
const onOpenSubMenu = (groupType: GroupType, value: string) => {
setOpenedMenuGroup(value);
setShowSubMenu(true);
const group = optionGroups[groupType].find((g) => {
return g.value === value;
});
if (group) {
setSubMenuOptions(group.options);
}
};
const onCloseSubMenu = (value: string) => {
setShowSubMenu(false);
setOpenedMenuGroup('');
setSubMenuOptions([]);
};
const onSelectedBuiltinRoleChange = (newRole: OrgRole) => { const onSelectedBuiltinRoleChange = (newRole: OrgRole) => {
setSelectedBuiltInRole(newRole); setSelectedBuiltInRole(newRole);
}; };
@ -200,10 +178,10 @@ export const RolePickerMenu = ({
setSelectedOptions([]); setSelectedOptions([]);
}; };
const onClearSubMenu = () => { const onClearSubMenu = (group: string) => {
const options = selectedOptions.filter((role) => { const options = selectedOptions.filter((role) => {
const groupName = getRoleGroup(role); const roleGroup = getRoleGroup(role);
return groupName !== openedMenuGroup; return roleGroup !== group;
}); });
setSelectedOptions(options); setSelectedOptions(options);
}; };
@ -239,33 +217,23 @@ export const RolePickerMenu = ({
/> />
</div> </div>
)} )}
{Object.entries(rolesCollection).map(([groupId, collection]) => { {Object.entries(rolesCollection).map(([groupId, collection]) => (
return ( <RoleMenuGroupsSection
<RoleMenuGroupsSection key={groupId}
key={groupId} roles={collection.roles}
roles={collection.roles} renderedName={collection.renderedName}
renderedName={collection.renderedName} showGroups={showGroups}
menuSectionStyle={customStyles.menuSection} optionGroups={collection.optionGroup}
groupHeaderStyle={customStyles.groupHeader} groupSelected={(group: string) => groupSelected(collection.groupType, group)}
optionBodyStyle={styles.optionBody} groupPartiallySelected={(group: string) => groupPartiallySelected(collection.groupType, group)}
showGroups={showGroups} onGroupChange={(group: string) => onGroupChange(collection.groupType, group)}
optionGroups={collection.optionGroup} subMenuNode={subMenuNode?.current!}
groupSelected={(group: string) => groupSelected(collection.groupType, group)} selectedOptions={selectedOptions}
groupPartiallySelected={(group: string) => groupPartiallySelected(collection.groupType, group)} onRoleChange={onChange}
onChange={(group: string) => onGroupChange(collection.groupType, group)} onClearSubMenu={onClearSubMenu}
onOpenSubMenuRMGS={(group: string) => onOpenSubMenu(collection.groupType, group)} showOnLeftSubMenu={offset.horizontal > 0}
onCloseSubMenu={onCloseSubMenu} />
subMenuNode={subMenuNode?.current!} ))}
showSubMenu={showSubMenu}
openedMenuGroup={openedMenuGroup}
subMenuOptions={subMenuOptions}
selectedOptions={selectedOptions}
onChangeSubMenu={onChange}
onClearSubMenu={onClearSubMenu}
showOnLeftSubMenu={offset.horizontal > 0}
></RoleMenuGroupsSection>
);
})}
</CustomScrollbar> </CustomScrollbar>
<div className={customStyles.menuButtonRow}> <div className={customStyles.menuButtonRow}>
<HorizontalGroup justify="flex-end"> <HorizontalGroup justify="flex-end">