From 6e2b148745c4a69d20a07577705b4eb1f372dad1 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Tue, 20 Dec 2022 13:05:37 +0300 Subject: [PATCH] Role picker: Split components into separate files (#60519) --- .../RolePicker/RoleMenuGroupOption.tsx | 104 ++++ .../RolePicker/RoleMenuGroupsSection.tsx | 113 +++++ .../components/RolePicker/RoleMenuOption.tsx | 62 +++ .../components/RolePicker/RolePickerMenu.tsx | 456 +----------------- .../RolePicker/RolePickerSubMenu.tsx | 76 +++ .../app/core/components/RolePicker/styles.ts | 112 +++++ .../app/core/components/RolePicker/utils.ts | 5 + 7 files changed, 480 insertions(+), 448 deletions(-) create mode 100644 public/app/core/components/RolePicker/RoleMenuGroupOption.tsx create mode 100644 public/app/core/components/RolePicker/RoleMenuGroupsSection.tsx create mode 100644 public/app/core/components/RolePicker/RoleMenuOption.tsx create mode 100644 public/app/core/components/RolePicker/RolePickerSubMenu.tsx create mode 100644 public/app/core/components/RolePicker/styles.ts create mode 100644 public/app/core/components/RolePicker/utils.ts diff --git a/public/app/core/components/RolePicker/RoleMenuGroupOption.tsx b/public/app/core/components/RolePicker/RoleMenuGroupOption.tsx new file mode 100644 index 00000000000..b411affd10d --- /dev/null +++ b/public/app/core/components/RolePicker/RoleMenuGroupOption.tsx @@ -0,0 +1,104 @@ +import { cx } from '@emotion/css'; +import React, { FormEvent } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { Checkbox, Portal, useStyles2, useTheme2 } from '@grafana/ui'; +import { getSelectStyles } from '@grafana/ui/src/components/Select/getSelectStyles'; + +import { getStyles } from './styles'; + +interface RoleMenuGroupsOptionProps { + data: SelectableValue; + onChange: (value: string) => void; + onClick?: (value: string) => void; + onOpenSubMenu?: (value: string) => void; + onCloseSubMenu?: (value: string) => void; + isSelected?: boolean; + partiallySelected?: boolean; + isFocused?: boolean; + disabled?: boolean; + children?: React.ReactNode; + root?: HTMLElement; +} + +export const RoleMenuGroupOption = React.forwardRef( + ( + { + data, + isFocused, + isSelected, + partiallySelected, + disabled, + onChange, + onClick, + onOpenSubMenu, + onCloseSubMenu, + children, + root, + }, + ref + ) => { + const theme = useTheme2(); + const styles = getSelectStyles(theme); + const customStyles = useStyles2(getStyles); + + const wrapperClassName = cx( + styles.option, + isFocused && styles.optionFocused, + disabled && customStyles.menuOptionDisabled + ); + + const onChangeInternal = (event: FormEvent) => { + if (disabled) { + return; + } + if (data.value) { + onChange(data.value); + } + }; + + const onClickInternal = (event: FormEvent) => { + if (onClick) { + onClick(data.value!); + } + }; + + const onMouseEnter = () => { + if (onOpenSubMenu) { + onOpenSubMenu(data.value!); + } + }; + + const onMouseLeave = () => { + if (onCloseSubMenu) { + onCloseSubMenu(data.value!); + } + }; + + return ( +
+
+ +
+ {data.displayName || data.name} + +
+ {root && children && ( + + {children} + + )} +
+
+ ); + } +); + +RoleMenuGroupOption.displayName = 'RoleMenuGroupOption'; diff --git a/public/app/core/components/RolePicker/RoleMenuGroupsSection.tsx b/public/app/core/components/RolePicker/RoleMenuGroupsSection.tsx new file mode 100644 index 00000000000..51f0319f661 --- /dev/null +++ b/public/app/core/components/RolePicker/RoleMenuGroupsSection.tsx @@ -0,0 +1,113 @@ +import React from 'react'; + +import { Role } from 'app/types'; + +import { RoleMenuGroupOption } from './RoleMenuGroupOption'; +import { RoleMenuOption } from './RoleMenuOption'; +import { RolePickerSubMenu } from './RolePickerSubMenu'; +import { isNotDelegatable } from './utils'; + +interface RoleMenuGroupsSectionProps { + roles: Role[]; + renderedName: string; + menuSectionStyle: string; + groupHeaderStyle: string; + optionBodyStyle: string; + showGroups?: boolean; + optionGroups: Array<{ + name: string; + options: Role[]; + value: string; + uid: string; + }>; + onChange: (value: string) => void; + onOpenSubMenuRMGS: (value: string) => void; + onCloseSubMenu?: (value: string) => void; + groupSelected: (group: string) => boolean; + groupPartiallySelected: (group: string) => boolean; + disabled?: boolean; + subMenuNode?: HTMLDivElement; + showSubMenu: boolean; + openedMenuGroup: string; + subMenuOptions: Role[]; + selectedOptions: Role[]; + onChangeSubMenu: (option: Role) => void; + onClearSubMenu: () => void; + showOnLeftSubMenu: boolean; +} + +export const RoleMenuGroupsSection = React.forwardRef( + ( + { + roles, + renderedName, + menuSectionStyle, + groupHeaderStyle, + optionBodyStyle, + showGroups, + optionGroups, + onChange, + groupSelected, + groupPartiallySelected, + onOpenSubMenuRMGS, + onCloseSubMenu, + subMenuNode, + showSubMenu, + openedMenuGroup, + subMenuOptions, + selectedOptions, + onChangeSubMenu, + onClearSubMenu, + showOnLeftSubMenu, + }, + _ref + ) => { + return ( +
+ {roles.length > 0 && ( +
+
{renderedName}
+
+ {showGroups && !!optionGroups?.length + ? optionGroups.map((option) => ( + + {showSubMenu && openedMenuGroup === option.value && ( + + )} + + )) + : roles.map((option) => ( + opt.uid === option.uid))} + disabled={isNotDelegatable(option)} + onChange={onChangeSubMenu} + hideDescription + /> + ))} +
+ )} +
+ ); + } +); + +RoleMenuGroupsSection.displayName = 'RoleMenuGroupsSection'; diff --git a/public/app/core/components/RolePicker/RoleMenuOption.tsx b/public/app/core/components/RolePicker/RoleMenuOption.tsx new file mode 100644 index 00000000000..065a8544a95 --- /dev/null +++ b/public/app/core/components/RolePicker/RoleMenuOption.tsx @@ -0,0 +1,62 @@ +import { cx } from '@emotion/css'; +import React, { FormEvent } from 'react'; + +import { Checkbox, Icon, Tooltip, useStyles2, useTheme2 } from '@grafana/ui'; +import { getSelectStyles } from '@grafana/ui/src/components/Select/getSelectStyles'; +import { Role } from 'app/types'; + +import { getStyles } from './styles'; + +interface RoleMenuOptionProps { + data: Role; + onChange: (value: Role) => void; + isSelected?: boolean; + isFocused?: boolean; + disabled?: boolean; + hideDescription?: boolean; +} + +export const RoleMenuOption = React.forwardRef>( + ({ data, isFocused, isSelected, disabled, onChange, hideDescription }, ref) => { + const theme = useTheme2(); + const styles = getSelectStyles(theme); + const customStyles = useStyles2(getStyles); + + const wrapperClassName = cx( + styles.option, + isFocused && styles.optionFocused, + disabled && customStyles.menuOptionDisabled + ); + + const onChangeInternal = (event: FormEvent) => { + if (disabled) { + return; + } + event.preventDefault(); + event.stopPropagation(); + onChange(data); + }; + + return ( +
+ +
+ {data.displayName || data.name} + {!hideDescription && data.description &&
{data.description}
} +
+ {data.description && ( + + + + )} +
+ ); + } +); + +RoleMenuOption.displayName = 'RoleMenuOption'; diff --git a/public/app/core/components/RolePicker/RolePickerMenu.tsx b/public/app/core/components/RolePicker/RolePickerMenu.tsx index 43cfc5b75a0..febf7649ab7 100644 --- a/public/app/core/components/RolePicker/RolePickerMenu.tsx +++ b/public/app/core/components/RolePicker/RolePickerMenu.tsx @@ -1,24 +1,15 @@ import { css, cx } from '@emotion/css'; -import React, { FormEvent, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { v4 as uuidv4 } from 'uuid'; -import { GrafanaTheme2, SelectableValue } from '@grafana/data'; -import { - Button, - Checkbox, - CustomScrollbar, - HorizontalGroup, - Icon, - Portal, - RadioButtonGroup, - Tooltip, - useStyles2, - useTheme2, -} from '@grafana/ui'; +import { SelectableValue } from '@grafana/data'; +import { Button, CustomScrollbar, HorizontalGroup, RadioButtonGroup, useStyles2, useTheme2 } from '@grafana/ui'; import { getSelectStyles } from '@grafana/ui/src/components/Select/getSelectStyles'; import { OrgRole, Role } from 'app/types'; -import { MENU_MAX_HEIGHT, ROLE_PICKER_SUBMENU_MIN_WIDTH } from './constants'; +import { RoleMenuGroupsSection } from './RoleMenuGroupsSection'; +import { MENU_MAX_HEIGHT } from './constants'; +import { getStyles } from './styles'; enum GroupType { fixed = 'fixed', @@ -297,443 +288,12 @@ const convertRolesToGroupOptions = (roles: Role[]) => { return groups; }; -interface RolePickerSubMenuProps { - options: Role[]; - selectedOptions: Role[]; - disabledOptions?: Role[]; - onSelect: (option: Role) => void; - onClear?: () => void; - showOnLeft?: boolean; -} - -export const RolePickerSubMenu = ({ - options, - selectedOptions, - disabledOptions, - onSelect, - onClear, - showOnLeft, -}: RolePickerSubMenuProps): JSX.Element => { - const theme = useTheme2(); - const styles = getSelectStyles(theme); - const customStyles = useStyles2(getStyles); - - const onClearInternal = async () => { - if (onClear) { - onClear(); - } - }; - - return ( -
- -
- {options.map((option, i) => ( - opt.uid === option.uid) || - disabledOptions?.find((opt) => opt.uid === option.uid)) - ) - } - disabled={ - !!(option.uid && disabledOptions?.find((opt) => opt.uid === option.uid)) || isNotDelegatable(option) - } - onChange={onSelect} - hideDescription - /> - ))} -
-
-
- - - -
-
- ); -}; - -interface RoleMenuOptionProps { - data: Role; - onChange: (value: Role) => void; - isSelected?: boolean; - isFocused?: boolean; - disabled?: boolean; - hideDescription?: boolean; -} - -export const RoleMenuOption = React.forwardRef>( - ({ data, isFocused, isSelected, disabled, onChange, hideDescription }, ref) => { - const theme = useTheme2(); - const styles = getSelectStyles(theme); - const customStyles = useStyles2(getStyles); - - const wrapperClassName = cx( - styles.option, - isFocused && styles.optionFocused, - disabled && customStyles.menuOptionDisabled - ); - - const onChangeInternal = (event: FormEvent) => { - if (disabled) { - return; - } - event.preventDefault(); - event.stopPropagation(); - onChange(data); - }; - - return ( -
- -
- {data.displayName || data.name} - {!hideDescription && data.description &&
{data.description}
} -
- {data.description && ( - - - - )} -
- ); - } -); - -RoleMenuOption.displayName = 'RoleMenuOption'; - -interface RoleMenuGroupsSectionProps { - roles: Role[]; - renderedName: string; - menuSectionStyle: string; - groupHeaderStyle: string; - optionBodyStyle: string; - showGroups?: boolean; - optionGroups: Array<{ - name: string; - options: Role[]; - value: string; - uid: string; - }>; - onChange: (value: string) => void; - onOpenSubMenuRMGS: (value: string) => void; - onCloseSubMenu?: (value: string) => void; - groupSelected: (group: string) => boolean; - groupPartiallySelected: (group: string) => boolean; - disabled?: boolean; - subMenuNode?: HTMLDivElement; - showSubMenu: boolean; - openedMenuGroup: string; - subMenuOptions: Role[]; - selectedOptions: Role[]; - onChangeSubMenu: (option: Role) => void; - onClearSubMenu: () => void; - showOnLeftSubMenu: boolean; -} - -export const RoleMenuGroupsSection = React.forwardRef( - ( - { - roles, - renderedName, - menuSectionStyle, - groupHeaderStyle, - optionBodyStyle, - showGroups, - optionGroups, - onChange, - groupSelected, - groupPartiallySelected, - onOpenSubMenuRMGS, - onCloseSubMenu, - subMenuNode, - showSubMenu, - openedMenuGroup, - subMenuOptions, - selectedOptions, - onChangeSubMenu, - onClearSubMenu, - showOnLeftSubMenu, - }, - _ref - ) => { - return ( -
- {roles.length > 0 && ( -
-
{renderedName}
-
- {showGroups && !!optionGroups?.length - ? optionGroups.map((option) => ( - - {showSubMenu && openedMenuGroup === option.value && ( - - )} - - )) - : roles.map((option) => ( - opt.uid === option.uid))} - disabled={isNotDelegatable(option)} - onChange={onChangeSubMenu} - hideDescription - /> - ))} -
- )} -
- ); - } -); - -RoleMenuGroupsSection.displayName = 'RoleMenuGroupsSection'; - -interface RoleMenuGroupsOptionProps { - data: SelectableValue; - onChange: (value: string) => void; - onClick?: (value: string) => void; - onOpenSubMenu?: (value: string) => void; - onCloseSubMenu?: (value: string) => void; - isSelected?: boolean; - partiallySelected?: boolean; - isFocused?: boolean; - disabled?: boolean; - children?: React.ReactNode; - root?: HTMLElement; -} - -export const RoleMenuGroupOption = React.forwardRef( - ( - { - data, - isFocused, - isSelected, - partiallySelected, - disabled, - onChange, - onClick, - onOpenSubMenu, - onCloseSubMenu, - children, - root, - }, - ref - ) => { - const theme = useTheme2(); - const styles = getSelectStyles(theme); - const customStyles = useStyles2(getStyles); - - const wrapperClassName = cx( - styles.option, - isFocused && styles.optionFocused, - disabled && customStyles.menuOptionDisabled - ); - - const onChangeInternal = (event: FormEvent) => { - if (disabled) { - return; - } - if (data.value) { - onChange(data.value); - } - }; - - const onClickInternal = (event: FormEvent) => { - if (onClick) { - onClick(data.value!); - } - }; - - const onMouseEnter = () => { - if (onOpenSubMenu) { - onOpenSubMenu(data.value!); - } - }; - - const onMouseLeave = () => { - if (onCloseSubMenu) { - onCloseSubMenu(data.value!); - } - }; - - return ( -
-
- -
- {data.displayName || data.name} - -
- {root && children && ( - - {children} - - )} -
-
- ); - } -); - -RoleMenuGroupOption.displayName = 'RoleMenuGroupOption'; - const getRoleGroup = (role: Role) => { return role.group || 'Other'; }; +const sortRolesByName = (a: Role, b: Role) => a.name.localeCompare(b.name); + const capitalize = (s: string): string => { return s.slice(0, 1).toUpperCase() + s.slice(1); }; - -const sortRolesByName = (a: Role, b: Role) => a.name.localeCompare(b.name); - -const isNotDelegatable = (role: Role) => { - return role.delegatable !== undefined && !role.delegatable; -}; - -export const getStyles = (theme: GrafanaTheme2) => { - return { - menuWrapper: css` - display: flex; - max-height: 650px; - position: absolute; - z-index: ${theme.zIndex.dropdown}; - overflow: hidden; - min-width: auto; - `, - menu: css` - min-width: ${ROLE_PICKER_SUBMENU_MIN_WIDTH}px; - - & > div { - padding-top: ${theme.spacing(1)}; - } - `, - menuLeft: css` - right: 0; - flex-direction: row-reverse; - `, - subMenu: css` - height: 100%; - min-width: ${ROLE_PICKER_SUBMENU_MIN_WIDTH}px; - display: flex; - flex-direction: column; - border-left: 1px solid ${theme.components.input.borderColor}; - - & > div { - padding-top: ${theme.spacing(1)}; - } - `, - subMenuLeft: css` - border-right: 1px solid ${theme.components.input.borderColor}; - border-left: unset; - `, - groupHeader: css` - padding: ${theme.spacing(0, 4)}; - display: flex; - align-items: center; - color: ${theme.colors.text.primary}; - font-weight: ${theme.typography.fontWeightBold}; - `, - container: css` - padding: ${theme.spacing(1)}; - border: 1px ${theme.colors.border.weak} solid; - border-radius: ${theme.shape.borderRadius(1)}; - background-color: ${theme.colors.background.primary}; - z-index: ${theme.zIndex.modal}; - `, - menuSection: css` - margin-bottom: ${theme.spacing(2)}; - `, - menuOptionCheckbox: css` - display: flex; - margin: ${theme.spacing(0, 1, 0, 0.25)}; - `, - menuButtonRow: css` - background-color: ${theme.colors.background.primary}; - padding: ${theme.spacing(1)}; - `, - menuOptionBody: css` - font-weight: ${theme.typography.fontWeightRegular}; - padding: ${theme.spacing(0, 1.5, 0, 0)}; - `, - menuOptionDisabled: css` - color: ${theme.colors.text.disabled}; - cursor: not-allowed; - `, - menuOptionExpand: css` - position: absolute; - right: ${theme.spacing(1.25)}; - color: ${theme.colors.text.disabled}; - - &:after { - content: '>'; - } - `, - menuOptionInfoSign: css` - color: ${theme.colors.text.disabled}; - `, - basicRoleSelector: css` - margin: ${theme.spacing(1, 1.25, 1, 1)}; - `, - subMenuPortal: css` - height: 100%; - > div { - height: 100%; - } - `, - subMenuButtonRow: css` - background-color: ${theme.colors.background.primary}; - padding: ${theme.spacing(1)}; - `, - checkboxPartiallyChecked: css` - input { - &:checked + span { - &:after { - border-width: 0 3px 0px 0; - transform: rotate(90deg); - } - } - } - `, - }; -}; diff --git a/public/app/core/components/RolePicker/RolePickerSubMenu.tsx b/public/app/core/components/RolePicker/RolePickerSubMenu.tsx new file mode 100644 index 00000000000..4f6b5aa2f9f --- /dev/null +++ b/public/app/core/components/RolePicker/RolePickerSubMenu.tsx @@ -0,0 +1,76 @@ +import { cx } from '@emotion/css'; +import React from 'react'; + +import { Button, CustomScrollbar, HorizontalGroup, useStyles2, useTheme2 } from '@grafana/ui'; +import { getSelectStyles } from '@grafana/ui/src/components/Select/getSelectStyles'; +import { Role } from 'app/types'; + +import { RoleMenuOption } from './RoleMenuOption'; +import { MENU_MAX_HEIGHT } from './constants'; +import { getStyles } from './styles'; +import { isNotDelegatable } from './utils'; + +interface RolePickerSubMenuProps { + options: Role[]; + selectedOptions: Role[]; + disabledOptions?: Role[]; + onSelect: (option: Role) => void; + onClear?: () => void; + showOnLeft?: boolean; +} + +export const RolePickerSubMenu = ({ + options, + selectedOptions, + disabledOptions, + onSelect, + onClear, + showOnLeft, +}: RolePickerSubMenuProps): JSX.Element => { + const theme = useTheme2(); + const styles = getSelectStyles(theme); + const customStyles = useStyles2(getStyles); + + const onClearInternal = async () => { + if (onClear) { + onClear(); + } + }; + + return ( +
+ +
+ {options.map((option, i) => ( + opt.uid === option.uid) || + disabledOptions?.find((opt) => opt.uid === option.uid)) + ) + } + disabled={ + !!(option.uid && disabledOptions?.find((opt) => opt.uid === option.uid)) || isNotDelegatable(option) + } + onChange={onSelect} + hideDescription + /> + ))} +
+
+
+ + + +
+
+ ); +}; diff --git a/public/app/core/components/RolePicker/styles.ts b/public/app/core/components/RolePicker/styles.ts new file mode 100644 index 00000000000..901c21d5202 --- /dev/null +++ b/public/app/core/components/RolePicker/styles.ts @@ -0,0 +1,112 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { ROLE_PICKER_SUBMENU_MIN_WIDTH } from './constants'; + +export const getStyles = (theme: GrafanaTheme2) => { + return { + menuWrapper: css` + display: flex; + max-height: 650px; + position: absolute; + z-index: ${theme.zIndex.dropdown}; + overflow: hidden; + min-width: auto; + `, + menu: css` + min-width: ${ROLE_PICKER_SUBMENU_MIN_WIDTH}px; + + & > div { + padding-top: ${theme.spacing(1)}; + } + `, + menuLeft: css` + right: 0; + flex-direction: row-reverse; + `, + subMenu: css` + height: 100%; + min-width: ${ROLE_PICKER_SUBMENU_MIN_WIDTH}px; + display: flex; + flex-direction: column; + border-left: 1px solid ${theme.components.input.borderColor}; + + & > div { + padding-top: ${theme.spacing(1)}; + } + `, + subMenuLeft: css` + border-right: 1px solid ${theme.components.input.borderColor}; + border-left: unset; + `, + groupHeader: css` + padding: ${theme.spacing(0, 4)}; + display: flex; + align-items: center; + color: ${theme.colors.text.primary}; + font-weight: ${theme.typography.fontWeightBold}; + `, + container: css` + padding: ${theme.spacing(1)}; + border: 1px ${theme.colors.border.weak} solid; + border-radius: ${theme.shape.borderRadius(1)}; + background-color: ${theme.colors.background.primary}; + z-index: ${theme.zIndex.modal}; + `, + menuSection: css` + margin-bottom: ${theme.spacing(2)}; + `, + menuOptionCheckbox: css` + display: flex; + margin: ${theme.spacing(0, 1, 0, 0.25)}; + `, + menuButtonRow: css` + background-color: ${theme.colors.background.primary}; + padding: ${theme.spacing(1)}; + `, + menuOptionBody: css` + font-weight: ${theme.typography.fontWeightRegular}; + padding: ${theme.spacing(0, 1.5, 0, 0)}; + `, + menuOptionDisabled: css` + color: ${theme.colors.text.disabled}; + cursor: not-allowed; + `, + menuOptionExpand: css` + position: absolute; + right: ${theme.spacing(1.25)}; + color: ${theme.colors.text.disabled}; + + &:after { + content: '>'; + } + `, + menuOptionInfoSign: css` + color: ${theme.colors.text.disabled}; + `, + basicRoleSelector: css` + margin: ${theme.spacing(1, 1.25, 1, 1)}; + `, + subMenuPortal: css` + height: 100%; + > div { + height: 100%; + } + `, + subMenuButtonRow: css` + background-color: ${theme.colors.background.primary}; + padding: ${theme.spacing(1)}; + `, + checkboxPartiallyChecked: css` + input { + &:checked + span { + &:after { + border-width: 0 3px 0px 0; + transform: rotate(90deg); + } + } + } + `, + }; +}; diff --git a/public/app/core/components/RolePicker/utils.ts b/public/app/core/components/RolePicker/utils.ts new file mode 100644 index 00000000000..15f95936ef5 --- /dev/null +++ b/public/app/core/components/RolePicker/utils.ts @@ -0,0 +1,5 @@ +import { Role } from 'app/types'; + +export const isNotDelegatable = (role: Role) => { + return role.delegatable !== undefined && !role.delegatable; +};