[WEB-3292] feat: workspace switcher redesign (#6543)

* feat: ui changes for workspace switcher

* fix: hover

* fix: added current plan

* feat: Return user role

* chore: remove unused imports

* fix: css

* fix: added user role in workspace switcher

* fix: return role as integer

* fix: role casing

* fix: refactor

* fix: plan pill fix

* fix: design updates

* fix: refactor

* fix: member translation

* fix: css improvements

* fix: truncate issue

* fix: workspace switcher dropdown email truncate

* fix: workspace switcher dropdown email truncate

* fix: role

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so>
This commit is contained in:
Akshita Goyal 2025-02-07 20:05:57 +05:30 committed by GitHub
parent 456c7f55a9
commit 20ba91b98c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 172 additions and 112 deletions

View file

@ -32,10 +32,10 @@ from django.core.exceptions import ValidationError
class WorkSpaceSerializer(DynamicBaseSerializer):
owner = UserLiteSerializer(read_only=True)
total_members = serializers.IntegerField(read_only=True)
total_issues = serializers.IntegerField(read_only=True)
logo_url = serializers.CharField(read_only=True)
role = serializers.IntegerField(read_only=True)
def validate_slug(self, value):
# Check if the slug is restricted

View file

@ -7,9 +7,11 @@ from datetime import date
from dateutil.relativedelta import relativedelta
from django.db import IntegrityError
from django.db.models import Count, F, Func, OuterRef, Prefetch, Q
from django.db.models.fields import DateField
from django.db.models.functions import Cast, ExtractDay, ExtractWeek
# Django imports
from django.http import HttpResponse
from django.utils import timezone
@ -173,6 +175,11 @@ class UserWorkSpacesEndpoint(BaseAPIView):
.values("count")
)
role = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True)
.values("role")
)
workspace = (
Workspace.objects.prefetch_related(
Prefetch(
@ -184,17 +191,19 @@ class UserWorkSpacesEndpoint(BaseAPIView):
)
.select_related("owner")
.annotate(total_members=member_count)
.annotate(total_issues=issue_count)
.annotate(total_issues=issue_count, role=role)
.filter(
workspace_member__member=request.user, workspace_member__is_active=True
)
.distinct()
)
workspaces = WorkSpaceSerializer(
self.filter_queryset(workspace),
fields=fields if fields else None,
many=True,
).data
return Response(workspaces, status=status.HTTP_200_OK)

View file

@ -23,6 +23,8 @@ export interface IWorkspace {
organization_size: string;
total_issues: number;
total_projects?: number;
current_plan?: string;
role: number;
}
export interface IWorkspaceLite {

View file

@ -0,0 +1,7 @@
import { IWorkspace } from "@plane/types";
type TProps = {
workspace: IWorkspace;
};
export const SubscriptionPill = (props: TProps) => <></>;

View file

@ -0,0 +1,106 @@
import Link from "next/link";
import { useParams } from "next/navigation";
import { Check, Settings, UserPlus } from "lucide-react";
import { Menu } from "@headlessui/react";
import { EUserPermissions } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IWorkspace } from "@plane/types";
import { cn, getFileURL } from "@plane/utils";
import { getUserRole } from "@/helpers/user.helper";
import { SubscriptionPill } from "@/plane-web/components/common/subscription-pill";
type TProps = {
workspace: IWorkspace;
activeWorkspace: IWorkspace | null;
handleItemClick: () => void;
handleWorkspaceNavigation: (workspace: IWorkspace) => void;
};
const SidebarDropdownItem = (props: TProps) => {
const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation } = props;
// router params
const { workspaceSlug } = useParams();
const { t } = useTranslation();
return (
<Link
key={workspace.id}
href={`/${workspace.slug}`}
onClick={() => {
handleWorkspaceNavigation(workspace);
handleItemClick();
}}
className="w-full"
id={workspace.id}
>
<Menu.Item
as="div"
className={cn("px-4 py-2", {
"bg-custom-sidebar-background-90": workspace.id === activeWorkspace?.id,
"hover:bg-custom-sidebar-background-90": workspace.id !== activeWorkspace?.id,
})}
>
<div className="flex items-center justify-between gap-1 rounded p-1 text-sm text-custom-sidebar-text-100 ">
<div className="flex items-center justify-start gap-2.5 w-[80%] relative">
<span
className={`relative flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 text-sm uppercase font-semibold ${
!workspace?.logo_url && "rounded-lg bg-custom-primary-500 text-white"
}`}
>
{workspace?.logo_url && workspace.logo_url !== "" ? (
<img
src={getFileURL(workspace.logo_url)}
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
alt={t("workspace_logo")}
/>
) : (
(workspace?.name?.[0] ?? "...")
)}
</span>
<div className="w-[inherit]">
<div
className={`truncate text-ellipsis text-sm font-medium ${workspaceSlug === workspace.slug ? "" : "text-custom-text-200"}`}
>
{workspace.name}
</div>
<div className="text-sm text-custom-text-300 flex gap-2 capitalize w-fit">
<span>{getUserRole(workspace.role)?.toLowerCase() || "guest"}</span>
<div className="w-1 h-1 bg-custom-text-300/50 rounded-full m-auto" />
<span className="capitalize">{t("member", { count: workspace.total_members || 0 })}</span>
</div>
</div>
</div>
{workspace.id === activeWorkspace?.id ? (
<span className="flex-shrink-0 p-1">
<Check className="h-5 w-5 text-custom-sidebar-text-100" />
</span>
) : (
<SubscriptionPill workspace={workspace} />
)}
</div>
{workspace.id === activeWorkspace?.id && (
<div className="mt-2 mb-1 flex gap-2">
{workspace?.role > EUserPermissions.GUEST && (
<Link
href={`/${workspace.slug}/settings`}
className="flex border border-custom-border-200 rounded-md py-1 px-2 gap-1 bg-custom-sidebar-background-100"
>
<Settings className="h-4 w-4 text-custom-sidebar-text-100 my-auto" />
<span className="text-sm font-medium my-auto">{t("settings")}</span>
</Link>
)}
<Link
href={`/${workspace.slug}/settings/members`}
className="flex border border-custom-border-200 rounded-md py-1 px-2 gap-1 bg-custom-sidebar-background-100"
>
<UserPlus className="h-4 w-4 text-custom-sidebar-text-100 my-auto" />
<span className="text-sm font-medium my-auto capitalize">{t("invite")}</span>
</Link>
</div>
)}
</Menu.Item>
</Link>
);
};
export default SidebarDropdownItem;

View file

@ -1,16 +1,14 @@
"use client";
import { Fragment, Ref, useState, useMemo } from "react";
import { Fragment, Ref, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { usePopper } from "react-popper";
// icons
import { Check, ChevronDown, LogOut, Mails, PlusSquare, Settings } from "lucide-react";
import { ChevronDown, CirclePlus, LogOut, Mails, Settings } from "lucide-react";
// ui
import { Menu, Transition } from "@headlessui/react";
// types
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IWorkspace } from "@plane/types";
// plane ui
@ -19,36 +17,16 @@ import { GOD_MODE_URL, cn } from "@/helpers/common.helper";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useAppTheme, useUser, useUserPermissions, useUserProfile, useWorkspace } from "@/hooks/store";
// plane web constants
import { useAppTheme, useUser, useUserProfile, useWorkspace } from "@/hooks/store";
// plane web helpers
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
// components
import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
export const SidebarDropdown = observer(() => {
const { t } = useTranslation();
const userLinks = useMemo(
() => (workspaceSlug: string) => [
{
key: "workspace_invites",
name: t("workspace_invites"),
href: "/invitations",
icon: Mails,
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
},
{
key: "settings",
name: t("workspace_settings.label"),
href: `/${workspaceSlug}/settings`,
icon: Settings,
access: [EUserPermissions.ADMIN],
},
],
[t]
);
// router params
const { workspaceSlug } = useParams();
// store hooks
const { sidebarCollapsed, toggleSidebar } = useAppTheme();
const { data: currentUser } = useUser();
@ -58,8 +36,6 @@ export const SidebarDropdown = observer(() => {
signOut,
} = useUser();
const { updateUserProfile } = useUserProfile();
const { allowPermissions } = useUserPermissions();
// derived values
const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
const isUserInstanceAdmin = false;
@ -150,57 +126,26 @@ export const SidebarDropdown = observer(() => {
>
<Menu.Items as={Fragment}>
<div className="fixed top-12 left-4 z-[21] mt-1 flex w-full max-w-[19rem] origin-top-left flex-col divide-y divide-custom-border-100 rounded-md border-[0.5px] border-custom-sidebar-border-300 bg-custom-sidebar-background-100 shadow-custom-shadow-rg outline-none">
<div className="vertical-scrollbar scrollbar-sm mb-2 flex max-h-96 flex-col items-start justify-start gap-2 overflow-y-scroll px-4">
<h6 className="sticky top-0 z-[21] h-full w-full bg-custom-sidebar-background-100 pb-1 pt-3 text-sm font-medium text-custom-sidebar-text-400">
<div className="overflow-x-hidden vertical-scrollbar scrollbar-sm flex max-h-96 flex-col items-start justify-start overflow-y-scroll">
<span className="rounded-md px-4 sticky top-0 z-[21] h-full w-full bg-custom-sidebar-background-100 pb-1 pt-3 text-sm font-medium text-custom-text-400 truncate flex-shrink-0">
{currentUser?.email}
</h6>
</span>
{workspacesList ? (
<div className="size-full flex flex-col items-start justify-start gap-1.5">
{workspacesList.map((workspace) => (
<Link
<div className="size-full flex flex-col items-start justify-start">
{(activeWorkspace
? [
activeWorkspace,
...workspacesList.filter((workspace) => workspace.id !== activeWorkspace?.id),
]
: workspacesList
).map((workspace) => (
<SidebarDropdownItem
key={workspace.id}
href={`/${workspace.slug}`}
onClick={() => {
handleWorkspaceNavigation(workspace);
handleItemClick();
}}
className="w-full"
>
<Menu.Item
as="div"
className="flex items-center justify-between gap-1 rounded p-1 text-sm text-custom-sidebar-text-100 hover:bg-custom-sidebar-background-80"
>
<div className="flex items-center justify-start gap-2.5 truncate">
<span
className={`relative flex h-6 w-6 flex-shrink-0 items-center justify-center p-2 text-xs uppercase ${
!workspace?.logo_url && "rounded bg-custom-primary-500 text-white"
}`}
>
{workspace?.logo_url && workspace.logo_url !== "" ? (
<img
src={getFileURL(workspace.logo_url)}
className="absolute left-0 top-0 h-full w-full rounded object-cover"
alt={t("workspace_logo")}
/>
) : (
(workspace?.name?.[0] ?? "...")
)}
</span>
<h5
className={`truncate text-sm font-medium ${
workspaceSlug === workspace.slug ? "" : "text-custom-text-200"
}`}
>
{workspace.name}
</h5>
</div>
{workspace.id === activeWorkspace?.id && (
<span className="flex-shrink-0 p-1">
<Check className="h-5 w-5 text-custom-sidebar-text-100" />
</span>
)}
</Menu.Item>
</Link>
workspace={workspace}
activeWorkspace={activeWorkspace}
handleItemClick={handleItemClick}
handleWorkspaceNavigation={handleWorkspaceNavigation}
/>
))}
</div>
) : (
@ -219,43 +164,33 @@ export const SidebarDropdown = observer(() => {
as="div"
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
>
<PlusSquare strokeWidth={1.75} className="h-4 w-4 flex-shrink-0" />
<CirclePlus className="size-4 flex-shrink-0" />
{t("create_workspace")}
</Menu.Item>
</Link>
)}
{userLinks(workspaceSlug?.toString() ?? "").map(
(link, index) =>
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE) && (
<Link
key={link.key}
href={link.href}
className="w-full"
onClick={() => {
if (index > 0) handleItemClick();
}}
>
<Menu.Item
as="div"
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
>
<link.icon className="h-4 w-4 flex-shrink-0" />
{link.name}
</Menu.Item>
</Link>
)
)}
</div>
<div className="w-full px-4 py-2">
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 text-sm font-medium text-red-600 hover:bg-custom-sidebar-background-80"
onClick={handleSignOut}
>
<LogOut className="size-4 flex-shrink-0" />
{t("sign_out")}
</Menu.Item>
<Link href="/invitations" className="w-full" onClick={handleItemClick}>
<Menu.Item
as="div"
className="flex items-center gap-2 rounded px-2 py-1 text-sm font-medium text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80"
>
<Mails className="h-4 w-4 flex-shrink-0" />
{t("workspace_invites")}
</Menu.Item>
</Link>
<div className="w-full">
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 text-sm font-medium text-red-600 hover:bg-custom-sidebar-background-80"
onClick={handleSignOut}
>
<LogOut className="size-4 flex-shrink-0" />
{t("sign_out")}
</Menu.Item>
</div>
</div>
</div>
</Menu.Items>

View file

@ -0,0 +1 @@
export * from "ce/components/common/subscription-pill";