[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): class WorkSpaceSerializer(DynamicBaseSerializer):
owner = UserLiteSerializer(read_only=True)
total_members = serializers.IntegerField(read_only=True) total_members = serializers.IntegerField(read_only=True)
total_issues = serializers.IntegerField(read_only=True) total_issues = serializers.IntegerField(read_only=True)
logo_url = serializers.CharField(read_only=True) logo_url = serializers.CharField(read_only=True)
role = serializers.IntegerField(read_only=True)
def validate_slug(self, value): def validate_slug(self, value):
# Check if the slug is restricted # Check if the slug is restricted

View file

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

View file

@ -23,6 +23,8 @@ export interface IWorkspace {
organization_size: string; organization_size: string;
total_issues: number; total_issues: number;
total_projects?: number; total_projects?: number;
current_plan?: string;
role: number;
} }
export interface IWorkspaceLite { 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"; "use client";
import { Fragment, Ref, useState, useMemo } from "react"; import { Fragment, Ref, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
// icons // icons
import { Check, ChevronDown, LogOut, Mails, PlusSquare, Settings } from "lucide-react"; import { ChevronDown, CirclePlus, LogOut, Mails, Settings } from "lucide-react";
// ui // ui
import { Menu, Transition } from "@headlessui/react"; import { Menu, Transition } from "@headlessui/react";
// types // types
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { IWorkspace } from "@plane/types"; import { IWorkspace } from "@plane/types";
// plane ui // plane ui
@ -19,36 +17,16 @@ import { GOD_MODE_URL, cn } from "@/helpers/common.helper";
// helpers // helpers
import { getFileURL } from "@/helpers/file.helper"; import { getFileURL } from "@/helpers/file.helper";
// hooks // hooks
import { useAppTheme, useUser, useUserPermissions, useUserProfile, useWorkspace } from "@/hooks/store"; import { useAppTheme, useUser, useUserProfile, useWorkspace } from "@/hooks/store";
// plane web constants
// plane web helpers // plane web helpers
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper"; import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
// components // components
import { WorkspaceLogo } from "../logo"; import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item";
export const SidebarDropdown = observer(() => { export const SidebarDropdown = observer(() => {
const { t } = useTranslation(); 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 // store hooks
const { sidebarCollapsed, toggleSidebar } = useAppTheme(); const { sidebarCollapsed, toggleSidebar } = useAppTheme();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
@ -58,8 +36,6 @@ export const SidebarDropdown = observer(() => {
signOut, signOut,
} = useUser(); } = useUser();
const { updateUserProfile } = useUserProfile(); const { updateUserProfile } = useUserProfile();
const { allowPermissions } = useUserPermissions();
// derived values
const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false; const isWorkspaceCreationEnabled = getIsWorkspaceCreationDisabled() === false;
const isUserInstanceAdmin = false; const isUserInstanceAdmin = false;
@ -150,57 +126,26 @@ export const SidebarDropdown = observer(() => {
> >
<Menu.Items as={Fragment}> <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="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"> <div className="overflow-x-hidden vertical-scrollbar scrollbar-sm flex max-h-96 flex-col items-start justify-start overflow-y-scroll">
<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"> <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} {currentUser?.email}
</h6> </span>
{workspacesList ? ( {workspacesList ? (
<div className="size-full flex flex-col items-start justify-start gap-1.5"> <div className="size-full flex flex-col items-start justify-start">
{workspacesList.map((workspace) => ( {(activeWorkspace
<Link ? [
activeWorkspace,
...workspacesList.filter((workspace) => workspace.id !== activeWorkspace?.id),
]
: workspacesList
).map((workspace) => (
<SidebarDropdownItem
key={workspace.id} key={workspace.id}
href={`/${workspace.slug}`} workspace={workspace}
onClick={() => { activeWorkspace={activeWorkspace}
handleWorkspaceNavigation(workspace); handleItemClick={handleItemClick}
handleItemClick(); handleWorkspaceNavigation={handleWorkspaceNavigation}
}} />
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>
))} ))}
</div> </div>
) : ( ) : (
@ -219,43 +164,33 @@ export const SidebarDropdown = observer(() => {
as="div" 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" 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")} {t("create_workspace")}
</Menu.Item> </Menu.Item>
</Link> </Link>
)} )}
{userLinks(workspaceSlug?.toString() ?? "").map(
(link, index) => <Link href="/invitations" className="w-full" onClick={handleItemClick}>
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE) && ( <Menu.Item
<Link as="div"
key={link.key} 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"
href={link.href} >
className="w-full" <Mails className="h-4 w-4 flex-shrink-0" />
onClick={() => { {t("workspace_invites")}
if (index > 0) handleItemClick(); </Menu.Item>
}} </Link>
>
<Menu.Item <div className="w-full">
as="div" <Menu.Item
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" as="button"
> type="button"
<link.icon className="h-4 w-4 flex-shrink-0" /> 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"
{link.name} onClick={handleSignOut}
</Menu.Item> >
</Link> <LogOut className="size-4 flex-shrink-0" />
) {t("sign_out")}
)} </Menu.Item>
</div> </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>
</div> </div>
</div> </div>
</Menu.Items> </Menu.Items>

View file

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