mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-10 07:18:10 -05:00
refactor(frontend): update role dropdown styles in the team page (org support) (#11906)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
This commit is contained in:
@@ -132,12 +132,14 @@ describe("Manage Team Route", () => {
|
||||
const memberListItems = await screen.findAllByTestId("member-item");
|
||||
const userRoleMember = memberListItems[2]; // third member is "user"
|
||||
|
||||
let userCombobox = within(userRoleMember).getByText(/user/i);
|
||||
let userCombobox = within(userRoleMember).getByText(/^User$/i);
|
||||
expect(userCombobox).toBeInTheDocument();
|
||||
await userEvent.click(userCombobox);
|
||||
|
||||
const dropdown = within(userRoleMember).getByTestId("role-dropdown");
|
||||
const adminOption = within(dropdown).getByText(/admin/i);
|
||||
const dropdown = within(userRoleMember).getByTestId(
|
||||
"organization-member-role-context-menu",
|
||||
);
|
||||
const adminOption = within(dropdown).getByTestId("admin-option");
|
||||
expect(adminOption).toBeInTheDocument();
|
||||
await userEvent.click(adminOption);
|
||||
|
||||
@@ -147,18 +149,22 @@ describe("Manage Team Route", () => {
|
||||
role: "admin",
|
||||
});
|
||||
expect(
|
||||
within(userRoleMember).queryByTestId("role-dropdown"),
|
||||
within(userRoleMember).queryByTestId(
|
||||
"organization-member-role-context-menu",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Verify the role has been updated in the UI
|
||||
userCombobox = within(userRoleMember).getByText(/admin/i);
|
||||
userCombobox = within(userRoleMember).getByText(/^Admin$/i);
|
||||
expect(userCombobox).toBeInTheDocument();
|
||||
|
||||
// revert the role back to user
|
||||
await userEvent.click(userCombobox);
|
||||
const userOption = within(
|
||||
within(userRoleMember).getByTestId("role-dropdown"),
|
||||
).getByText(/user/i);
|
||||
within(userRoleMember).getByTestId(
|
||||
"organization-member-role-context-menu",
|
||||
),
|
||||
).getByTestId("user-option");
|
||||
expect(userOption).toBeInTheDocument();
|
||||
await userEvent.click(userOption);
|
||||
|
||||
@@ -169,7 +175,7 @@ describe("Manage Team Route", () => {
|
||||
});
|
||||
|
||||
// Verify the role has been reverted in the UI
|
||||
userCombobox = within(userRoleMember).getByText(/user/i);
|
||||
userCombobox = within(userRoleMember).getByText(/^User$/i);
|
||||
expect(userCombobox).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -191,13 +197,15 @@ describe("Manage Team Route", () => {
|
||||
|
||||
const memberListItems = await screen.findAllByTestId("member-item");
|
||||
const ownerMember = memberListItems[0]; // first member is "owner
|
||||
const userCombobox = within(ownerMember).getByText(/owner/i);
|
||||
const userCombobox = within(ownerMember).getByText(/^Owner$/i);
|
||||
expect(userCombobox).toBeInTheDocument();
|
||||
await userEvent.click(userCombobox);
|
||||
|
||||
// Verify that the dropdown does not open for owner
|
||||
expect(
|
||||
within(ownerMember).queryByTestId("role-dropdown"),
|
||||
within(ownerMember).queryByTestId(
|
||||
"organization-member-role-context-menu",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -211,12 +219,14 @@ describe("Manage Team Route", () => {
|
||||
const adminMember = memberListItems[1]; // first member is "admin"
|
||||
expect(adminMember).toBeDefined();
|
||||
|
||||
const roleText = within(adminMember).getByText(/admin/i);
|
||||
const roleText = within(adminMember).getByText(/^Admin$/i);
|
||||
await userEvent.click(roleText);
|
||||
|
||||
// Verify that the dropdown does not open for the other admin
|
||||
expect(
|
||||
within(adminMember).queryByTestId("role-dropdown"),
|
||||
within(adminMember).queryByTestId(
|
||||
"organization-member-role-context-menu",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -238,12 +248,14 @@ describe("Manage Team Route", () => {
|
||||
const memberListItems = await screen.findAllByTestId("member-item");
|
||||
const currentUserMember = memberListItems[0]; // First member is Alice (id: "1")
|
||||
|
||||
const roleText = within(currentUserMember).getByText(/owner/i);
|
||||
const roleText = within(currentUserMember).getByText(/^Owner$/i);
|
||||
await userEvent.click(roleText);
|
||||
|
||||
// Verify that the dropdown does not open for the current user's own role
|
||||
expect(
|
||||
within(currentUserMember).queryByTestId("role-dropdown"),
|
||||
within(currentUserMember).queryByTestId(
|
||||
"organization-member-role-context-menu",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -263,18 +275,17 @@ describe("Manage Team Route", () => {
|
||||
const userEmail = within(userRoleMember).getByText("charlie@acme.org");
|
||||
expect(userEmail).toBeInTheDocument();
|
||||
|
||||
const userCombobox = within(userRoleMember).getByText(/user/i);
|
||||
const userCombobox = within(userRoleMember).getByText(/^User$/i);
|
||||
await userEvent.click(userCombobox);
|
||||
|
||||
const dropdown = within(userRoleMember).getByTestId("role-dropdown");
|
||||
const dropdown = within(userRoleMember).getByTestId(
|
||||
"organization-member-role-context-menu",
|
||||
);
|
||||
|
||||
// Check that remove option exists
|
||||
const removeOption = within(dropdown).getByText(/remove/i);
|
||||
const removeOption = within(dropdown).getByTestId("remove-option");
|
||||
expect(removeOption).toBeInTheDocument();
|
||||
|
||||
// Check that remove option has danger styling (red color)
|
||||
expect(removeOption).toHaveClass("text-red-500"); // or whatever danger class is used
|
||||
|
||||
await userEvent.click(removeOption);
|
||||
|
||||
expect(removeMemberSpy).toHaveBeenCalledExactlyOnceWith({
|
||||
@@ -370,9 +381,11 @@ describe("Manage Team Route", () => {
|
||||
expect(invitedBadge).toBeInTheDocument();
|
||||
|
||||
// should not have a role combobox
|
||||
await userEvent.click(within(invitedMember).getByText(/user/i));
|
||||
await userEvent.click(within(invitedMember).getByText(/^User$/i));
|
||||
expect(
|
||||
within(invitedMember).queryByTestId("role-dropdown"),
|
||||
within(invitedMember).queryByTestId(
|
||||
"organization-member-role-context-menu",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ChevronDown } from "lucide-react";
|
||||
import { OrganizationMember, OrganizationUserRole } from "#/types/org";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { OrganizationMemberRoleContextMenu } from "./organization-member-role-context-menu";
|
||||
|
||||
interface OrganizationMemberListItemProps {
|
||||
email: OrganizationMember["email"];
|
||||
@@ -24,16 +25,19 @@ export function OrganizationMemberListItem({
|
||||
onRemove,
|
||||
}: OrganizationMemberListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const [roleSelectionOpen, setRoleSelectionOpen] = React.useState(false);
|
||||
|
||||
const handleRoleSelectionClick = (newRole: OrganizationUserRole) => {
|
||||
onRoleChange(newRole);
|
||||
setRoleSelectionOpen(false);
|
||||
};
|
||||
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
|
||||
|
||||
const roleSelectionIsPermitted =
|
||||
status !== "invited" && hasPermissionToChangeRole;
|
||||
|
||||
const handleRoleClick = (event: React.MouseEvent<HTMLSpanElement>) => {
|
||||
if (roleSelectionIsPermitted) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setContextMenuOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -51,42 +55,25 @@ export function OrganizationMemberListItem({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
onClick={() => setRoleSelectionOpen(true)}
|
||||
className={cn(
|
||||
"text-xs text-gray-400 uppercase flex items-center gap-1",
|
||||
roleSelectionIsPermitted ? "cursor-pointer" : "cursor-not-allowed",
|
||||
<div className="relative">
|
||||
<span
|
||||
onClick={handleRoleClick}
|
||||
className={cn(
|
||||
"text-xs text-gray-400 flex items-center gap-1 capitalize",
|
||||
roleSelectionIsPermitted ? "cursor-pointer" : "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{role}
|
||||
{hasPermissionToChangeRole && <ChevronDown size={14} />}
|
||||
</span>
|
||||
{roleSelectionIsPermitted && contextMenuOpen && (
|
||||
<OrganizationMemberRoleContextMenu
|
||||
onClose={() => setContextMenuOpen(false)}
|
||||
onRoleChange={onRoleChange}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{role}
|
||||
{hasPermissionToChangeRole && <ChevronDown size={14} />}
|
||||
</span>
|
||||
|
||||
{roleSelectionIsPermitted && roleSelectionOpen && (
|
||||
<ul data-testid="role-dropdown">
|
||||
<li>
|
||||
<span onClick={() => handleRoleSelectionClick("admin")}>
|
||||
{t(I18nKey.ORG$ROLE_ADMIN)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span onClick={() => handleRoleSelectionClick("user")}>
|
||||
{t(I18nKey.ORG$ROLE_USER)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
className="text-red-500 cursor-pointer"
|
||||
onClick={() => {
|
||||
onRemove?.();
|
||||
setRoleSelectionOpen(false);
|
||||
}}
|
||||
>
|
||||
{t(I18nKey.ORG$REMOVE)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ContextMenu } from "#/ui/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { ContextMenuIconText } from "#/ui/context-menu-icon-text";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { OrganizationUserRole } from "#/types/org";
|
||||
import { cn } from "#/utils/utils";
|
||||
import UserIcon from "#/icons/user.svg?react";
|
||||
import DeleteIcon from "#/icons/u-delete.svg?react";
|
||||
import AdminIcon from "#/icons/admin.svg?react";
|
||||
|
||||
const contextMenuListItemClassName = cn(
|
||||
"cursor-pointer p-0 h-auto hover:bg-transparent",
|
||||
);
|
||||
|
||||
interface OrganizationMemberRoleContextMenuProps {
|
||||
onClose: () => void;
|
||||
onRoleChange: (role: OrganizationUserRole) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
export function OrganizationMemberRoleContextMenu({
|
||||
onClose,
|
||||
onRoleChange,
|
||||
onRemove,
|
||||
}: OrganizationMemberRoleContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
const handleAdminClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRoleChange("admin");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleUserClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRoleChange("user");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRemoveClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRemove?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={menuRef}
|
||||
testId="organization-member-role-context-menu"
|
||||
position="bottom"
|
||||
alignment="right"
|
||||
className="min-h-fit mb-2 min-w-[195px] max-w-[195px] gap-0"
|
||||
>
|
||||
<ContextMenuListItem
|
||||
testId="admin-option"
|
||||
onClick={handleAdminClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ContextMenuIconText
|
||||
icon={
|
||||
<AdminIcon width={16} height={16} className="text-white pl-[2px]" />
|
||||
}
|
||||
text={t(I18nKey.ORG$ROLE_ADMIN)}
|
||||
className="capitalize"
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuListItem
|
||||
testId="user-option"
|
||||
onClick={handleUserClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ContextMenuIconText
|
||||
icon={<UserIcon width={16} height={16} className="text-white" />}
|
||||
text={t(I18nKey.ORG$ROLE_USER)}
|
||||
className="capitalize"
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuListItem
|
||||
testId="remove-option"
|
||||
onClick={handleRemoveClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ContextMenuIconText
|
||||
icon={<DeleteIcon width={16} height={16} className="text-red-500" />}
|
||||
text={t(I18nKey.ORG$REMOVE)}
|
||||
className="text-red-500 capitalize"
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
3
frontend/src/icons/admin.svg
Normal file
3
frontend/src/icons/admin.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 13 12" fill="none">
|
||||
<path d="M5.93266 9.60059H2.99809C2.0034 9.60059 1.19828 10.4057 1.19828 11.4004C1.19815 11.7727 0.860332 12.0653 0.473671 11.9873C0.187333 11.9299 -0.00314397 11.6658 3.8147e-05 11.373C0.0144066 9.72907 1.35287 8.40039 3.00004 8.40039H4.34476L5.93266 9.60059ZM8.99027 6.50586C9.0747 6.2466 9.44203 6.24656 9.52641 6.50586H9.52445L10.0977 8.26758H11.9502C12.2224 8.26758 12.3365 8.6166 12.1153 8.77734L10.6162 9.86523L11.1895 11.627C11.2738 11.8863 10.9765 12.103 10.7569 11.9424L9.25785 10.8535L7.75785 11.9424C7.53665 12.1028 7.24091 11.8863 7.32523 11.627L7.89848 9.86523L6.39945 8.77734C6.17827 8.61663 6.29243 8.26768 6.56449 8.26758H8.41703L8.99027 6.50586ZM5.39945 0C7.38708 0 8.99979 1.61204 9.00004 3.59961C9.00004 5.58739 7.38723 7.2002 5.39945 7.2002C3.41175 7.20011 1.79984 5.58734 1.79984 3.59961C1.80009 1.61209 3.4119 8.31798e-05 5.39945 0ZM5.39945 1.2002C4.07396 1.20028 3.00029 2.27416 3.00004 3.59961C3.00004 4.92527 4.07381 5.99992 5.39945 6C6.72517 6 7.79984 4.92532 7.79984 3.59961C7.79959 2.27411 6.72501 1.2002 5.39945 1.2002Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
30
frontend/src/ui/context-menu-icon-text.tsx
Normal file
30
frontend/src/ui/context-menu-icon-text.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ContextMenuIconTextProps {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
rightIcon?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ContextMenuIconText({
|
||||
icon,
|
||||
text,
|
||||
rightIcon,
|
||||
className,
|
||||
}: ContextMenuIconTextProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between p-2 hover:bg-[#5C5D62] rounded",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
{text}
|
||||
</div>
|
||||
{rightIcon && <div className="flex items-center">{rightIcon}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user