[WEB-5758] feat: tips section (#5505)

* feat: endpoint to update onboarding related fields

* fix: endpoint and serializer name

* chore: explored features types, store and service updated

* chore sidebar wrapper component updated

* feat: try section implemeantion

* fix: format error

* chore: tips asset added

* chore: tips types updated

* chore: tips services added

* feat: tips section implementation

* chore: code refactoring

* chore: code refactoring

* fix: validation

* fix: type build error

* fix: format build error

---------

Co-authored-by: sangeethailango <[email protected]>
This commit is contained in:
Anmol Singh Bhatia
2026-01-21 19:53:10 +05:30
committed by GitHub
co-authored by sangeethailango
parent 06d05ed34f
commit 91e44834cb
14 changed files with 438 additions and 26 deletions
@@ -35,6 +35,7 @@ from .workspace import (
WorkspaceHomePreferenceSerializer,
StickySerializer,
WorkspaceUserMeSerializer,
WorkspaceMemberUserOnboardingSerializer,
)
from .project import (
ProjectSerializer,
@@ -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)
+7
View File
@@ -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/<str:slug>/workspace-member/me/onboarding/",
WorkspaceMemberUserOnboardingEndpoint.as_view(),
name="workspace-member-onboading",
),
]
+1
View File
@@ -66,6 +66,7 @@ from .workspace.member import (
WorkspaceMemberUserEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceMemberUserViewsEndpoint,
WorkspaceMemberUserOnboardingEndpoint,
)
from .workspace.invite import (
WorkspaceInvitationsViewset,
@@ -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
@@ -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 (
<SidebarWrapper title="Projects" quickActions={<SidebarQuickActions />}>
<SidebarMenuItems />
{/* Favorites Menu */}
{canPerformWorkspaceMemberActions && !isFavoriteEmpty && <SidebarFavoritesMenu />}
{/* Teams List */}
{showFavorites && <SidebarFavoritesMenu />}
<SidebarTeamsList />
{/* Projects List */}
<SidebarProjectsList />
</SidebarWrapper>
);
@@ -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<HTMLDivElement>(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 (
<>
<CustomizeNavigationDialog isOpen={isCustomizeNavDialogOpen} onClose={() => setIsCustomizeNavDialogOpen(false)} />
<div ref={ref} className="animate-fade-in flex flex-col h-full w-full">
<div ref={ref} className="relative animate-fade-in flex flex-col h-full w-full">
<div className="flex flex-col gap-3 px-3">
{/* Workspace switcher and settings */}
@@ -90,13 +101,16 @@ export const SidebarWrapper = observer(function SidebarWrapper(props: TSidebarWr
{children}
</ScrollArea>
{/* Help Section */}
<div className="flex items-center justify-between p-3 border-t border-subtle bg-surface-1 h-12">
<WorkspaceEditionBadge />
{/* TODO: To be checked if we need this */}
{/* <div className="flex items-center gap-2">
{!shouldRenderAppRail && <HelpMenu />}
{!isAppRailEnabled && <AppSidebarToggleButton />}
</div> */}
<div className="flex flex-col">
{isWorkspaceAdmin && (
<div className="flex flex-col gap-2 p-3">
<SidebarTrySection />
<SidebarTipSection />
</div>
)}
<div className="flex items-center justify-between p-3 border-t border-subtle bg-surface-1 h-12">
<WorkspaceEditionBadge />
</div>
</div>
</div>
</>
@@ -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 (
<div className="absolute bottom-0 left-0 right-0 z-100 flex justify-center items-center p-2.5 w-full">
<div className="w-full z-100 flex rounded-lg flex-col border border-subtle-1 bg-surface-2 shadow-raised-100">
<span className="relative min-h-36 w-full bg-layer-1 rounded-t-lg" style={backgroundStyle}>
<IconButton
icon={CloseIcon}
className="absolute top-2 right-2"
onClick={handleDismissTip}
variant="ghost"
size="sm"
aria-label="Dismiss tip"
/>
</span>
<div className="flex flex-col gap-1 p-3">
<span className="text-body-xs-semibold text-primary">{activeTip.title}</span>
<span className="text-body-xs-regular text-tertiary">{activeTip.description}</span>
</div>
</div>
</div>
);
});
@@ -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<TTrySectionItem[]>(
() => [
{
title: "Connect to GitHub",
icon: <GithubIcon className="size-4" />,
feature: "github_integrated",
getHref: (slug: string) => `/${slug}/settings/integrations/`,
},
{
title: "Connect to Slack",
icon: <SlackIcon className="size-4" />,
feature: "slack_integrated",
getHref: (slug: string) => `/${slug}/settings/integrations/`,
},
{
title: "Try Plane AI",
icon: <PiIcon className="size-4" />,
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 (
<Disclosure as="div" className="flex flex-col" defaultOpen>
<Disclosure.Button
className="group w-full flex items-center gap-2 px-2 py-1.5 rounded-sm text-placeholder hover:bg-layer-transparent-hover"
onClick={toggleOpen}
>
<span className="text-13 font-semibold">Try</span>
<ChevronRightIcon
className={cn("flex-shrink-0 size-3.5 transition-transform", {
"rotate-90": isOpen,
})}
/>
</Disclosure.Button>
<Transition
show={isOpen}
enter="transition duration-100 ease-out"
enterFrom="transform scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
>
<Disclosure.Panel as="div" className="flex flex-col gap-1" static>
{unexploredItems.map((item) => (
<SidebarNavItem key={item.feature} className="group h-7">
<Link
href={item.getHref(workspaceSlugStr)}
className="flex items-center gap-1.5 text-13 font-medium flex-grow text-tertiary"
aria-label={item.title}
>
{item.icon}
<span>{item.title}</span>
</Link>
<IconButton
variant="ghost"
size="sm"
icon={CloseIcon}
onClick={() => handleTryFeatureClick(item.feature)}
className="group-hover:flex hidden"
/>
</SidebarNavItem>
))}
</Disclosure.Panel>
</Transition>
</Disclosure>
);
});
@@ -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<Record<TExploredFeatures, boolean>>; tips?: Partial<Record<TTips, boolean>> }
): Promise<IWorkspaceMemberMe> {
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<any> {
return this.post(`/api/workspaces/${workspaceSlug}/workspace-views/`, data)
.then((response) => response?.data)
@@ -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<IWorkspaceMemberMe>;
updateExploredFeatures: (
workspaceSlug: string,
exploredFeatures: Partial<Record<TExploredFeatures, boolean>>
) => Promise<IWorkspaceMemberMe>;
updateTips: (workspaceSlug: string, tips: Partial<Record<TTips, boolean>>) => Promise<IWorkspaceMemberMe>;
leaveWorkspace: (workspaceSlug: string) => Promise<void>;
fetchUserProjectInfo: (workspaceSlug: string, projectId: string) => Promise<TProjectMembership>;
fetchUserProjectPermissions: (workspaceSlug: string) => Promise<IUserProjectsRole>;
@@ -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<Record<TExploredFeatures, boolean>> } exploredFeatures
* @returns { Promise<IWorkspaceMemberMe> }
*/
updateExploredFeatures = async (
workspaceSlug: string,
exploredFeatures: Partial<Record<TExploredFeatures, boolean>>
): Promise<IWorkspaceMemberMe> => {
try {
const existingFeatures = this.workspaceUserInfo[workspaceSlug]?.explored_features;
const filteredExisting: Partial<Record<TExploredFeatures, boolean>> = {};
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<Record<TTips, boolean>>): Promise<IWorkspaceMemberMe> => {
try {
const existingTips = this.workspaceUserInfo[workspaceSlug]?.tips;
const filteredExisting: Partial<Record<TTips, boolean>> = {};
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+6
View File
@@ -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<TExploredFeatures, boolean | null>;
tips: Record<TTips, boolean | null>;
}
export interface ILastActiveWorkspaceDetails {