diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py index 4ef3dc82c4..2b80ac21db 100644 --- a/apps/api/plane/app/serializers/__init__.py +++ b/apps/api/plane/app/serializers/__init__.py @@ -35,6 +35,7 @@ from .workspace import ( WorkspaceHomePreferenceSerializer, StickySerializer, WorkspaceUserMeSerializer, + WorkspaceMemberUserOnboardingSerializer, ) from .project import ( ProjectSerializer, diff --git a/apps/api/plane/app/serializers/workspace.py b/apps/api/plane/app/serializers/workspace.py index ac812b0cb3..f0f8cc0171 100644 --- a/apps/api/plane/app/serializers/workspace.py +++ b/apps/api/plane/app/serializers/workspace.py @@ -131,6 +131,13 @@ class WorkspaceMemberMeSerializer(BaseSerializer): fields = "__all__" +class WorkspaceMemberUserOnboardingSerializer(BaseSerializer): + class Meta: + model = WorkspaceMember + fields = ["workspace", "member", "getting_started_checklist", "tips", "explored_features"] + read_only_fields = ["workspace", "member"] + + class WorkspaceMemberAdminSerializer(DynamicBaseSerializer): member = UserAdminLiteSerializer(read_only=True) diff --git a/apps/api/plane/app/urls/workspace.py b/apps/api/plane/app/urls/workspace.py index f074afc255..ad92deea48 100644 --- a/apps/api/plane/app/urls/workspace.py +++ b/apps/api/plane/app/urls/workspace.py @@ -43,6 +43,7 @@ from plane.app.views import ( WorkspaceHomePreferenceViewSet, WorkspaceStickyViewSet, WorkspaceUserPreferenceViewSet, + WorkspaceMemberUserOnboardingEndpoint, ) @@ -264,4 +265,10 @@ urlpatterns = [ WorkspaceUserPreferenceViewSet.as_view(), name="workspace-user-preference", ), + # Onboading product tour + path( + "workspaces//workspace-member/me/onboarding/", + WorkspaceMemberUserOnboardingEndpoint.as_view(), + name="workspace-member-onboading", + ), ] diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 1caa93648f..86b5f44f51 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -66,6 +66,7 @@ from .workspace.member import ( WorkspaceMemberUserEndpoint, WorkspaceProjectMemberEndpoint, WorkspaceMemberUserViewsEndpoint, + WorkspaceMemberUserOnboardingEndpoint, ) from .workspace.invite import ( WorkspaceInvitationsViewset, diff --git a/apps/api/plane/app/views/workspace/member.py b/apps/api/plane/app/views/workspace/member.py index b565df87a4..640c9a1c70 100644 --- a/apps/api/plane/app/views/workspace/member.py +++ b/apps/api/plane/app/views/workspace/member.py @@ -30,6 +30,7 @@ from plane.app.serializers import ( WorkspaceMemberAdminSerializer, WorkspaceMemberMeSerializer, WorkSpaceMemberSerializer, + WorkspaceMemberUserOnboardingSerializer, ) from plane.app.views.base import BaseAPIView from plane.utils.cache import invalidate_cache @@ -344,6 +345,34 @@ class WorkspaceMemberUserEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) +# For WorkspaceMember fields: +# - getting_started_checklist +# - tips +# - explored_features +class WorkspaceMemberUserOnboardingEndpoint(BaseAPIView): + def patch(self, request, slug): + try: + workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, member_id=request.user.id) + + except WorkspaceMember.DoesNotExist: + return Response( + {"error": "You are not a member of this workspace"}, + status=status.HTTP_403_FORBIDDEN, + ) + + serializer = WorkspaceMemberUserOnboardingSerializer( + workspace_member, + data=request.data, + partial=True, + ) + + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_200_OK) + + class WorkspaceProjectMemberEndpoint(BaseAPIView): serializer_class = ProjectMemberRoleSerializer model = ProjectMember diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx index 6f329975d1..2b44a43eea 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx @@ -11,44 +11,39 @@ * NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. */ -import type { FC } from "react"; -import { isEmpty } from "lodash-es"; +import { useMemo } from "react"; import { observer } from "mobx-react"; -// plane helpers import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -// components import { SidebarWrapper } from "@/components/sidebar/sidebar-wrapper"; import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu"; import { SidebarProjectsList } from "@/components/workspace/sidebar/projects-list"; import { SidebarQuickActions } from "@/components/workspace/sidebar/quick-actions"; import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items"; -// hooks import { useFavorite } from "@/hooks/store/use-favorite"; import { useUserPermissions } from "@/hooks/store/user"; -// plane web components import { SidebarTeamsList } from "@/plane-web/components/workspace/sidebar/teamspaces/root"; export const AppSidebar = observer(function AppSidebar() { - // store hooks const { allowPermissions } = useUserPermissions(); const { groupedFavorites } = useFavorite(); - // derived values - const canPerformWorkspaceMemberActions = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.WORKSPACE + const canPerformWorkspaceMemberActions = useMemo( + () => allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.WORKSPACE), + [allowPermissions] ); - const isFavoriteEmpty = isEmpty(groupedFavorites); + const hasFavorites = useMemo(() => { + if (!groupedFavorites) return false; + return Object.keys(groupedFavorites).length > 0; + }, [groupedFavorites]); + + const showFavorites = canPerformWorkspaceMemberActions && hasFavorites; return ( }> - {/* Favorites Menu */} - {canPerformWorkspaceMemberActions && !isFavoriteEmpty && } - {/* Teams List */} + {showFavorites && } - {/* Projects List */} ); diff --git a/apps/web/core/components/sidebar/sidebar-wrapper.tsx b/apps/web/core/components/sidebar/sidebar-wrapper.tsx index dbf52fe90a..66c8da6abc 100644 --- a/apps/web/core/components/sidebar/sidebar-wrapper.tsx +++ b/apps/web/core/components/sidebar/sidebar-wrapper.tsx @@ -11,9 +11,10 @@ * NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; // plane helpers +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useOutsideClickDetector } from "@plane/hooks"; import { PreferencesIcon } from "@plane/propel/icons"; import { ScrollArea } from "@plane/propel/scrollarea"; @@ -21,11 +22,14 @@ import { ScrollArea } from "@plane/propel/scrollarea"; import { CustomizeNavigationDialog } from "@/components/navigation/customize-navigation-dialog"; // hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; +import { useUserPermissions } from "@/hooks/store/user/user-permissions"; import useSize from "@/hooks/use-window-size"; // plane web components import { WorkspaceEditionBadge } from "@/plane-web/components/workspace/edition-badge"; import { AppSidebarToggleButton } from "./sidebar-toggle-button"; import { IconButton } from "@plane/propel/icon-button"; +import { SidebarTipSection } from "../workspace/sidebar/sidebar-tip-section"; +import { SidebarTrySection } from "../workspace/sidebar/sidebar-try-section"; type TSidebarWrapperProps = { title: string; @@ -40,9 +44,16 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr // store hooks const { toggleSidebar, sidebarCollapsed } = useAppTheme(); const windowSize = useSize(); + const { allowPermissions } = useUserPermissions(); + // refs const ref = useRef(null); + const isWorkspaceAdmin = useMemo( + () => allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE), + [allowPermissions] + ); + useOutsideClickDetector(ref, () => { if (sidebarCollapsed === false && window.innerWidth < 768) { toggleSidebar(); @@ -57,7 +68,7 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr return ( <> setIsCustomizeNavDialogOpen(false)} /> -
+
{/* Workspace switcher and settings */} @@ -90,13 +101,16 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr {children} {/* Help Section */} -
- - {/* TODO: To be checked if we need this */} - {/*
- {!shouldRenderAppRail && } - {!isAppRailEnabled && } -
*/} +
+ {isWorkspaceAdmin && ( +
+ + +
+ )} +
+ +
diff --git a/apps/web/core/components/workspace/sidebar/sidebar-tip-section.tsx b/apps/web/core/components/workspace/sidebar/sidebar-tip-section.tsx new file mode 100644 index 0000000000..b9ecfdb5e8 --- /dev/null +++ b/apps/web/core/components/workspace/sidebar/sidebar-tip-section.tsx @@ -0,0 +1,103 @@ +/** + * SPDX-FileCopyrightText: 2023-present Plane Software, Inc. + * SPDX-License-Identifier: LicenseRef-Plane-Commercial + * + * Licensed under the Plane Commercial License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://plane.so/legals/eula + * + * DO NOT remove or modify this notice. + * NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. + */ + +import { useMemo, useCallback } from "react"; +import { useParams } from "next/navigation"; +import { useTheme } from "next-themes"; +import { observer } from "mobx-react"; +import type { TTips } from "@plane/types"; +import { IconButton } from "@plane/propel/icon-button"; +import { CloseIcon } from "@plane/propel/icons"; +import { useUserPermissions } from "@/hooks/store/user"; + +type TTipItem = { + id: TTips; + title: string; + description: string; + imageLight: string; + imageDark: string; +}; + +const TIPS_CONFIG: TTipItem[] = [ + { + id: "mobile_app_download", + title: "Download Plane for mobile", + description: "Manage your work on the go. Download Plane on Play Store and App Store.", + imageLight: "/tips/download-app/download-app-light.webp", + imageDark: "/tips/download-app/download-app-dark.webp", + }, + // Add more tips here in the future +]; + +export const SidebarTipSection = observer(() => { + const { workspaceSlug } = useParams(); + const { resolvedTheme } = useTheme(); + const { workspaceInfoBySlug, updateTips } = useUserPermissions(); + + const workspaceSlugStr = workspaceSlug?.toString() ?? ""; + const currentWorkspaceInfo = workspaceInfoBySlug(workspaceSlugStr); + + // Find the first tip that hasn't been dismissed + const activeTip = useMemo(() => { + if (!currentWorkspaceInfo?.tips) return TIPS_CONFIG[0]; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return TIPS_CONFIG.find((tip) => currentWorkspaceInfo.tips[tip.id] !== true) ?? null; + }, [currentWorkspaceInfo?.tips]); + + const backgroundImage = useMemo( + () => (activeTip ? (resolvedTheme === "dark" ? activeTip.imageDark : activeTip.imageLight) : ""), + [resolvedTheme, activeTip] + ); + + const handleDismissTip = useCallback(() => { + if (!workspaceSlugStr || !activeTip) return; + void updateTips(workspaceSlugStr, { [activeTip.id]: true }); + }, [workspaceSlugStr, activeTip, updateTips]); + + const backgroundStyle = useMemo( + () => ({ + backgroundImage: `url(${backgroundImage})`, + backgroundSize: "cover" as const, + backgroundPosition: "center" as const, + backgroundRepeat: "no-repeat" as const, + }), + [backgroundImage] + ); + + // Don't show if no active tip + if (!activeTip) { + return null; + } + + return ( +
+
+ + + +
+ {activeTip.title} + {activeTip.description} +
+
+
+ ); +}); diff --git a/apps/web/core/components/workspace/sidebar/sidebar-try-section.tsx b/apps/web/core/components/workspace/sidebar/sidebar-try-section.tsx new file mode 100644 index 0000000000..1a135c6fe5 --- /dev/null +++ b/apps/web/core/components/workspace/sidebar/sidebar-try-section.tsx @@ -0,0 +1,137 @@ +/** + * SPDX-FileCopyrightText: 2023-present Plane Software, Inc. + * SPDX-License-Identifier: LicenseRef-Plane-Commercial + * + * Licensed under the Plane Commercial License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * https://plane.so/legals/eula + * + * DO NOT remove or modify this notice. + * NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited. + */ + +import { useState, useMemo, useCallback } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { Disclosure, Transition } from "@headlessui/react"; +import { observer } from "mobx-react"; +import { EProductSubscriptionEnum } from "@plane/types"; +import type { TExploredFeatures } from "@plane/types"; +import { IconButton } from "@plane/propel/icon-button"; +import { ChevronRightIcon, GithubIcon, SlackIcon, PiIcon, CloseIcon } from "@plane/propel/icons"; +import { cn } from "@plane/utils"; +import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation"; +import { useUserPermissions } from "@/hooks/store/user"; +import { useWorkspaceSubscription } from "@/plane-web/hooks/store/use-workspace-subscription"; + +type TTrySectionItem = { + title: string; + icon: React.ReactNode; + feature: TExploredFeatures; + getHref: (workspaceSlug: string) => string; +}; + +export const SidebarTrySection = observer(() => { + const [isOpen, setIsOpen] = useState(true); + const { workspaceSlug } = useParams(); + + const { workspaceInfoBySlug, updateExploredFeatures } = useUserPermissions(); + const { currentWorkspaceSubscribedPlanDetail: subscriptionDetail } = useWorkspaceSubscription(); + + const workspaceSlugStr = workspaceSlug?.toString() ?? ""; + const currentWorkspaceInfo = workspaceInfoBySlug(workspaceSlugStr); + const currentSubscription = subscriptionDetail?.product; + + const trySectionItems = useMemo( + () => [ + { + title: "Connect to GitHub", + icon: , + feature: "github_integrated", + getHref: (slug: string) => `/${slug}/settings/integrations/`, + }, + { + title: "Connect to Slack", + icon: , + feature: "slack_integrated", + getHref: (slug: string) => `/${slug}/settings/integrations/`, + }, + { + title: "Try Plane AI", + icon: , + feature: "ai_chat_tried", + getHref: (slug: string) => `/${slug}/pi-chat/`, + }, + ], + [] + ); + + const unexploredItems = useMemo(() => { + if (!currentWorkspaceInfo?.explored_features) return trySectionItems; + + return trySectionItems.filter((item) => !currentWorkspaceInfo.explored_features?.[item.feature]); + }, [currentWorkspaceInfo?.explored_features, trySectionItems]); + + const handleTryFeatureClick = useCallback( + (feature: TExploredFeatures) => { + if (!workspaceSlugStr) return; + void updateExploredFeatures(workspaceSlugStr, { [feature]: true }); + }, + [workspaceSlugStr, updateExploredFeatures] + ); + + const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []); + + // Early returns for when section should not be shown + if (unexploredItems.length === 0 || currentSubscription === EProductSubscriptionEnum.FREE) { + return null; + } + + return ( + + + Try + + + + + {unexploredItems.map((item) => ( + + + {item.icon} + {item.title} + + handleTryFeatureClick(item.feature)} + className="group-hover:flex hidden" + /> + + ))} + + + + ); +}); diff --git a/apps/web/core/services/workspace.service.ts b/apps/web/core/services/workspace.service.ts index f250b1ce08..58cf5902e7 100644 --- a/apps/web/core/services/workspace.service.ts +++ b/apps/web/core/services/workspace.service.ts @@ -33,6 +33,8 @@ import type { IWorkspaceSidebarNavigationItem, IWorkspaceSidebarNavigation, IWorkspaceUserPropertiesResponse, + TExploredFeatures, + TTips, } from "@plane/types"; // services import { APIService } from "@/services/api.service"; @@ -132,6 +134,17 @@ export class WorkspaceService extends APIService { }); } + async updateMemberOnboarding( + workspaceSlug: string, + data: { explored_features?: Partial>; tips?: Partial> } + ): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/workspace-member/me/onboarding/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async updateWorkspaceView(workspaceSlug: string, data: { view_props: IWorkspaceViewProps }): Promise { return this.post(`/api/workspaces/${workspaceSlug}/workspace-views/`, data) .then((response) => response?.data) diff --git a/apps/web/core/store/user/base-permissions.store.ts b/apps/web/core/store/user/base-permissions.store.ts index 10a6b31c65..23f1f2995c 100644 --- a/apps/web/core/store/user/base-permissions.store.ts +++ b/apps/web/core/store/user/base-permissions.store.ts @@ -21,7 +21,14 @@ import { EUserPermissionsLevel, WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS, } from "@plane/constants"; -import type { EUserProjectRoles, IUserProjectsRole, IWorkspaceMemberMe, TProjectMembership } from "@plane/types"; +import type { + EUserProjectRoles, + IUserProjectsRole, + IWorkspaceMemberMe, + TExploredFeatures, + TProjectMembership, + TTips, +} from "@plane/types"; import { EUserWorkspaceRoles } from "@plane/types"; // plane web imports import { WorkspaceService } from "@/plane-web/services"; @@ -59,6 +66,11 @@ export interface IBaseUserPermissionStore { ) => boolean; // actions fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise; + updateExploredFeatures: ( + workspaceSlug: string, + exploredFeatures: Partial> + ) => Promise; + updateTips: (workspaceSlug: string, tips: Partial>) => Promise; leaveWorkspace: (workspaceSlug: string) => Promise; fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise; fetchUserProjectPermissions: (workspaceSlug: string) => Promise; @@ -89,6 +101,8 @@ export abstract class BaseUserPermissionStore implements IBaseUserPermissionStor // computed // actions fetchUserWorkspaceInfo: action, + updateExploredFeatures: action, + updateTips: action, leaveWorkspace: action, fetchUserProjectInfo: action, fetchUserProjectPermissions: action, @@ -259,6 +273,91 @@ export abstract class BaseUserPermissionStore implements IBaseUserPermissionStor } }; + /** + * @description Updates the explored features for the current user in a workspace + * @param { string } workspaceSlug + * @param { Partial> } exploredFeatures + * @returns { Promise } + */ + updateExploredFeatures = async ( + workspaceSlug: string, + exploredFeatures: Partial> + ): Promise => { + try { + const existingFeatures = this.workspaceUserInfo[workspaceSlug]?.explored_features; + const filteredExisting: Partial> = {}; + + if (existingFeatures) { + (Object.keys(existingFeatures) as TExploredFeatures[]).forEach((key) => { + const value = existingFeatures[key]; + if (value !== null) { + filteredExisting[key] = value; + } + }); + } + + const response = await workspaceService.updateMemberOnboarding(workspaceSlug, { + explored_features: { + ...filteredExisting, + ...exploredFeatures, + }, + }); + if (response) { + runInAction(() => { + set(this.workspaceUserInfo, [workspaceSlug], { + ...this.workspaceUserInfo[workspaceSlug], + explored_features: { + ...this.workspaceUserInfo[workspaceSlug]?.explored_features, + ...exploredFeatures, + }, + }); + }); + } + return response; + } catch (error) { + console.error("Error updating explored features", error); + throw error; + } + }; + + updateTips = async (workspaceSlug: string, tips: Partial>): Promise => { + try { + const existingTips = this.workspaceUserInfo[workspaceSlug]?.tips; + const filteredExisting: Partial> = {}; + + if (existingTips) { + (Object.keys(existingTips) as TTips[]).forEach((key) => { + const value = existingTips[key]; + if (value !== null) { + filteredExisting[key] = value; + } + }); + } + + const response = await workspaceService.updateMemberOnboarding(workspaceSlug, { + tips: { + ...filteredExisting, + ...tips, + }, + }); + if (response) { + runInAction(() => { + set(this.workspaceUserInfo, [workspaceSlug], { + ...this.workspaceUserInfo[workspaceSlug], + tips: { + ...this.workspaceUserInfo[workspaceSlug]?.tips, + ...tips, + }, + }); + }); + } + return response; + } catch (error) { + console.error("Error updating tips", error); + throw error; + } + }; + /** * @description Leaves a workspace * @param { string } workspaceSlug diff --git a/apps/web/public/tips/download-app/download-app-dark.webp b/apps/web/public/tips/download-app/download-app-dark.webp new file mode 100644 index 0000000000..664981ab10 Binary files /dev/null and b/apps/web/public/tips/download-app/download-app-dark.webp differ diff --git a/apps/web/public/tips/download-app/download-app-light.webp b/apps/web/public/tips/download-app/download-app-light.webp new file mode 100644 index 0000000000..d79cc9b93a Binary files /dev/null and b/apps/web/public/tips/download-app/download-app-light.webp differ diff --git a/packages/types/src/workspace.ts b/packages/types/src/workspace.ts index 665564d6c6..6c781f9c1b 100644 --- a/packages/types/src/workspace.ts +++ b/packages/types/src/workspace.ts @@ -91,6 +91,10 @@ export type Properties = { updated_on: boolean; }; +export type TExploredFeatures = "github_integrated" | "slack_integrated" | "ai_chat_tried"; + +export type TTips = "mobile_app_download"; + export interface IWorkspaceMember { id: string; member: IUserLite; @@ -120,6 +124,8 @@ export interface IWorkspaceMemberMe { workspace: string; draft_issue_count: number; active_cycles_count: number; + explored_features: Record; + tips: Record; } export interface ILastActiveWorkspaceDetails {