grafana/public/app/core/components/RolePicker/RolePicker.tsx
Yudintsev George (Egor) e124ee79f4
Chore: RBAC: Migrate role picker to rtkq (#116571)
What is this feature?

This PR introduce refactoring for role picker for teams and users. It uses RTK Query.
The related task: grafana/identity-access-team#1821

The previous PR with related logic: #113783

Why do we need this feature?

This refactoring is a cleaner way for handing refetching.
2026-02-03 13:46:41 +00:00

254 lines
7.9 KiB
TypeScript

import { FormEvent, useCallback, useEffect, useRef, useState, useSyncExternalStore, type JSX } from 'react';
import { OrgRole } from '@grafana/data';
import { ClickOutsideWrapper, Portal, useTheme2 } from '@grafana/ui';
import { pickerStateStore } from 'app/core/utils/roles';
import type { Role } from 'app/types/accessControl';
import { RolePickerInput } from './RolePickerInput';
import { RolePickerMenu } from './RolePickerMenu';
import {
MENU_MAX_HEIGHT,
ROLE_PICKER_MAX_MENU_WIDTH,
ROLE_PICKER_MENU_MAX_WIDTH,
ROLE_PICKER_WIDTH,
} from './constants';
export interface Props {
basicRole?: OrgRole;
appliedRoles: Role[];
roleOptions: Role[];
isLoading?: boolean;
disabled?: boolean;
basicRoleDisabled?: boolean;
basicRoleDisabledMessage?: string;
showBasicRole?: boolean;
onRolesChange: (newRoles: Role[]) => void;
onBasicRoleChange?: (newRole: OrgRole) => void;
canUpdateRoles?: boolean;
/**
* Set {@link RolePickerMenu}'s button to display either `Apply` (apply=true) or `Update` (apply=false)
*/
apply?: boolean;
maxWidth?: string | number;
width?: string | number;
/** Unique identifier for this picker instance to persist open/closed state across remounts */
pickerId?: string;
}
export const RolePicker = ({
basicRole,
appliedRoles,
roleOptions,
disabled,
isLoading,
basicRoleDisabled,
basicRoleDisabledMessage,
showBasicRole,
onRolesChange,
onBasicRoleChange,
canUpdateRoles = true,
apply = false,
maxWidth = ROLE_PICKER_WIDTH,
width,
pickerId,
}: Props): JSX.Element | null => {
// Generate stable picker ID if not provided
const stablePickerIdRef = useRef(pickerId || crypto.randomUUID());
const stablePickerId = stablePickerIdRef.current;
// Use external store for isOpen state so it survives remounts
const isOpen = useSyncExternalStore(pickerStateStore.subscribe, () => pickerStateStore.getState(stablePickerId));
const setOpen = useCallback(
(value: boolean) => {
pickerStateStore.setState(stablePickerId, value);
},
[stablePickerId]
);
const [selectedRoles, setSelectedRoles] = useState<Role[]>(appliedRoles);
const [selectedBuiltInRole, setSelectedBuiltInRole] = useState<OrgRole | undefined>(basicRole);
const [query, setQuery] = useState('');
const [offset, setOffset] = useState({ vertical: 0, horizontal: 0 });
const [menuLeft, setMenuLeft] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const theme = useTheme2();
const widthPx = typeof width === 'number' ? theme.spacing(width) : width;
// Sync internal state only when picker closes (transitions from open to closed)
useEffect(() => {
setSelectedBuiltInRole(basicRole);
setSelectedRoles(appliedRoles);
}, [appliedRoles, basicRole]);
const setMenuPosition = useCallback(() => {
const { horizontal, vertical, menuToLeft } = calculateMenuPosition();
if (horizontal && vertical) {
setOffset({ horizontal, vertical });
setMenuLeft(menuToLeft);
}
}, []);
useEffect(() => {
if (!isOpen) {
return;
}
setMenuPosition();
}, [isOpen, selectedRoles, setMenuPosition]);
const calculateMenuPosition = () => {
const dimensions = ref?.current?.getBoundingClientRect();
if (!dimensions) {
return {};
}
const { bottom, top, left, right } = dimensions;
const spaceBelow = window.innerHeight - bottom;
const spaceAbove = top;
const spaceRight = window.innerWidth - right;
const spaceLeft = left;
let horizontal = left;
let vertical = bottom;
let menuToLeft = false;
let menuToTop = false;
// Check vertical space
if (spaceBelow < MENU_MAX_HEIGHT && spaceAbove > spaceBelow) {
vertical = top - MENU_MAX_HEIGHT;
menuToTop = true;
}
// Check horizontal space
if (spaceRight < ROLE_PICKER_MENU_MAX_WIDTH && spaceLeft < ROLE_PICKER_MENU_MAX_WIDTH) {
horizontal = right - ROLE_PICKER_MENU_MAX_WIDTH;
menuToLeft = true;
} else {
horizontal = Math.max(0, left + (dimensions.width - ROLE_PICKER_MENU_MAX_WIDTH) / 2);
}
// Ensure the menu stays within the viewport
horizontal = Math.max(0, Math.min(horizontal, window.innerWidth - ROLE_PICKER_MAX_MENU_WIDTH));
vertical = Math.max(0, Math.min(vertical, window.innerHeight - MENU_MAX_HEIGHT));
if (menuToTop) {
// Adjust vertical position to align with the input
vertical -= 48;
}
return { horizontal, vertical, menuToLeft };
};
const onOpen = useCallback(
(event: FormEvent<HTMLElement>) => {
if (!disabled) {
event.preventDefault();
event.stopPropagation();
setMenuPosition();
setOpen(true);
}
},
[disabled, setMenuPosition, setOpen]
);
const onClose = useCallback(() => {
setOpen(false);
setQuery('');
setSelectedRoles(appliedRoles);
setSelectedBuiltInRole(basicRole);
}, [appliedRoles, basicRole, setOpen]);
// Only call onClose if menu is open. Prevent unnecessary calls for multiple pickers on the page.
const onClickOutside = () => isOpen && onClose();
const onInputChange = (query?: string) => {
if (query) {
setQuery(query);
} else {
setQuery('');
}
};
const onSelect = (roles: Role[]) => {
setSelectedRoles(roles);
};
const onBasicRoleSelect = (role: OrgRole) => {
setSelectedBuiltInRole(role);
};
const onUpdate = (newRoles: Role[], newBuiltInRole?: OrgRole) => {
if (onBasicRoleChange && newBuiltInRole && newBuiltInRole !== basicRole) {
onBasicRoleChange(newBuiltInRole);
}
if (canUpdateRoles) {
onRolesChange(newRoles);
}
setQuery('');
setOpen(false);
};
const getOptions = () => {
// if roles cannot be updated mark every role as non delegatable
const options = roleOptions.map((r) => ({ ...r, delegatable: canUpdateRoles && r.delegatable }));
if (query && query.trim() !== '') {
// TODO should this filter on `displayName` not (or in addition to) `name`?
return options.filter((option) => option.name?.toLowerCase().includes(query.toLowerCase()));
}
return options;
};
return (
<div
data-testid="role-picker"
style={{
position: 'relative',
maxWidth: widthPx || maxWidth,
width: widthPx,
}}
ref={ref}
>
<ClickOutsideWrapper onClick={onClickOutside} useCapture={false}>
<RolePickerInput
basicRole={selectedBuiltInRole}
appliedRoles={selectedRoles}
query={query}
onQueryChange={onInputChange}
onOpen={onOpen}
onClose={onClose}
isFocused={isOpen}
disabled={disabled}
showBasicRole={showBasicRole}
width={widthPx}
isLoading={isLoading}
/>
{isOpen && (
<Portal>
{/* Since menu rendered in portal and whole component wrapped in ClickOutsideWrapper, */}
{/* we need to stop event propagation to prevent closing menu */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div onClick={(e) => e.stopPropagation()}>
<RolePickerMenu
options={getOptions()}
isFiltered={query.trim() !== ''}
basicRole={selectedBuiltInRole}
appliedRoles={appliedRoles}
onBasicRoleSelect={onBasicRoleSelect}
onSelect={onSelect}
onUpdate={onUpdate}
showGroups={query.length === 0 || query.trim() === ''}
basicRoleDisabled={basicRoleDisabled}
disabledMessage={basicRoleDisabledMessage}
showBasicRole={showBasicRole}
updateDisabled={basicRoleDisabled && !canUpdateRoles}
apply={apply}
offset={offset}
menuLeft={menuLeft}
/>
</div>
</Portal>
)}
</ClickOutsideWrapper>
</div>
);
};