Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 22 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apiserver/plane/app/serializers/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@


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
Expand Down
11 changes: 10 additions & 1 deletion apiserver/plane/app/views/workspace/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
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
Expand Down Expand Up @@ -173,6 +175,11 @@ def get(self, request):
.values("count")
)

role = (
WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True)
.values("role")
)

workspace = (
Workspace.objects.prefetch_related(
Prefetch(
Expand All @@ -184,17 +191,19 @@ def get(self, request):
)
.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)


Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/workspace.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface IWorkspace {
organization_size: string;
total_issues: number;
total_projects?: number;
current_plan?: string;
role: number;
sriramveeraghanta marked this conversation as resolved.
Show resolved Hide resolved
}

export interface IWorkspaceLite {
Expand Down
7 changes: 7 additions & 0 deletions web/ce/components/common/subscription-pill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IWorkspace } from "@plane/types";

type TProps = {
workspace: IWorkspace;
};

export const SubscriptionPill = (props: TProps) => <></>;
106 changes: 106 additions & 0 deletions web/core/components/workspace/sidebar/dropdown-item.tsx
Original file line number Diff line number Diff line change
@@ -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">
sriramveeraghanta marked this conversation as resolved.
Show resolved Hide resolved
{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;
155 changes: 45 additions & 110 deletions web/core/components/workspace/sidebar/dropdown.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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">
sriramveeraghanta marked this conversation as resolved.
Show resolved Hide resolved
<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>
) : (
Expand All @@ -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>
Expand Down
1 change: 1 addition & 0 deletions web/ee/components/common/subscription-pill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "ce/components/common/subscription-pill";
Loading