[WEB-2917] Fix home widget (#6560)

* fix: home loading state

* fix: quickstart guide

* fix: link handling

* fix: home completed state

* fix: translations
This commit is contained in:
Akshita Goyal 2025-02-07 20:00:59 +05:30 committed by GitHub
parent c2da3ea4c8
commit 456c7f55a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 158 additions and 56 deletions

View file

@ -376,6 +376,8 @@
"home": { "home": {
"empty": { "empty": {
"quickstart_guide": "Your quickstart guide",
"not_right_now": "Not right now",
"create_project": { "create_project": {
"title": "Create a project", "title": "Create a project",
"description": "Most things start with a project in Plane.", "description": "Most things start with a project in Plane.",

View file

@ -546,6 +546,8 @@
"home": { "home": {
"empty": { "empty": {
"quickstart_guide": "Guía de inicio rápido",
"not_right_now": "Ahora no",
"create_project": { "create_project": {
"title": "Crear un proyecto", "title": "Crear un proyecto",
"description": "La mayoría de las cosas comienzan con un proyecto en Plane.", "description": "La mayoría de las cosas comienzan con un proyecto en Plane.",

View file

@ -546,6 +546,8 @@
"home": { "home": {
"empty": { "empty": {
"quickstart_guide": "Guide de démarrage rapide",
"not_right_now": "Pas maintenant",
"create_project": { "create_project": {
"title": "Créer un projet", "title": "Créer un projet",
"description": "La plupart des choses commencent par un projet dans Plane.", "description": "La plupart des choses commencent par un projet dans Plane.",

View file

@ -546,6 +546,8 @@
"home": { "home": {
"empty": { "empty": {
"quickstart_guide": "クイックスタートガイド",
"not_right_now": "今はしない",
"create_project": { "create_project": {
"title": "プロジェクトを作成", "title": "プロジェクトを作成",
"description": "Planeのほとんどはプロジェクトから始まります。", "description": "Planeのほとんどはプロジェクトから始まります。",

View file

@ -546,6 +546,8 @@
"home": { "home": {
"empty": { "empty": {
"quickstart_guide": "快速入门指南",
"not_right_now": "暂时不要",
"create_project": { "create_project": {
"title": "创建项目", "title": "创建项目",
"description": "在Plane中大多数事情都从项目开始。", "description": "在Plane中大多数事情都从项目开始。",

View file

@ -1,17 +1,18 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
// plane imports // plane imports
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types"; import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types";
// components // components
import { SimpleEmptyState } from "@/components/empty-state"; import { SimpleEmptyState } from "@/components/empty-state";
// hooks // hooks
import { useProject } from "@/hooks/store";
import { useHome } from "@/hooks/store/use-home"; import { useHome } from "@/hooks/store/use-home";
// plane web components // plane web components
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { HomePageHeader } from "@/plane-web/components/home/header"; import { HomePageHeader } from "@/plane-web/components/home/header";
import { StickiesWidget } from "../stickies"; import { StickiesWidget } from "../stickies";
import { RecentActivityWidget } from "./widgets"; import { HomeLoader, NoProjectsEmptyState, RecentActivityWidget } from "./widgets";
import { DashboardQuickLinks } from "./widgets/links"; import { DashboardQuickLinks } from "./widgets/links";
import { ManageWidgetsModal } from "./widgets/manage"; import { ManageWidgetsModal } from "./widgets/manage";
@ -52,14 +53,21 @@ export const HOME_WIDGETS_LIST: {
export const DashboardWidgets = observer(() => { export const DashboardWidgets = observer(() => {
// router // router
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
// navigation
const pathname = usePathname();
// store hooks
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets, isAnyWidgetEnabled, loading } =
useHome();
const { loader } = useProject();
// plane hooks // plane hooks
const { t } = useTranslation(); const { t } = useTranslation();
// store hooks
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets, isAnyWidgetEnabled } = useHome();
// derived values // derived values
const noWidgetsResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/dashboard/widgets" }); const noWidgetsResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/dashboard/widgets" });
// derived values
const isWikiApp = pathname.includes(`/${workspaceSlug.toString()}/pages`);
if (!workspaceSlug) return null; if (!workspaceSlug) return null;
if (loading || loader !== "loaded") return <HomeLoader />;
return ( return (
<div className="h-full w-full relative flex flex-col gap-7"> <div className="h-full w-full relative flex flex-col gap-7">
@ -69,6 +77,8 @@ export const DashboardWidgets = observer(() => {
isModalOpen={showWidgetSettings} isModalOpen={showWidgetSettings}
handleOnClose={() => toggleWidgetSettings(false)} handleOnClose={() => toggleWidgetSettings(false)}
/> />
{!isWikiApp && <NoProjectsEmptyState />}
{isAnyWidgetEnabled ? ( {isAnyWidgetEnabled ? (
<div className="flex flex-col divide-y-[1px] divide-custom-border-100"> <div className="flex flex-col divide-y-[1px] divide-custom-border-100">
{orderedWidgets.map((key) => { {orderedWidgets.map((key) => {

View file

@ -1,17 +1,21 @@
import React from "react"; import React from "react";
// mobx
import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Briefcase, Hotel, Users } from "lucide-react"; import { Briefcase, Check, Hotel, Users, X } from "lucide-react";
// plane ui // plane ui
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
// helpers // helpers
import { cn } from "@plane/utils";
import { getFileURL } from "@/helpers/file.helper"; import { getFileURL } from "@/helpers/file.helper";
// hooks // hooks
import { useCommandPalette, useEventTracker, useUser, useUserPermissions } from "@/hooks/store"; import { useCommandPalette, useEventTracker, useProject, useUser, useUserPermissions } from "@/hooks/store";
// plane web constants // plane web constants
export const NoProjectsEmptyState = () => { export const NoProjectsEmptyState = observer(() => {
// navigation // navigation
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
// store hooks // store hooks
@ -19,6 +23,14 @@ export const NoProjectsEmptyState = () => {
const { toggleCreateProjectModal } = useCommandPalette(); const { toggleCreateProjectModal } = useCommandPalette();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { joinedProjectIds } = useProject();
// local storage
const { storedValue, setValue } = useLocalStorage(`quickstart-guide-${workspaceSlug}`, {
hide: false,
visited_members: false,
visited_workspace: false,
visited_profile: false,
});
const { t } = useTranslation(); const { t } = useTranslation();
// derived values // derived values
const canCreateProject = allowPermissions( const canCreateProject = allowPermissions(
@ -31,7 +43,8 @@ export const NoProjectsEmptyState = () => {
id: "create-project", id: "create-project",
title: "home.empty.create_project.title", title: "home.empty.create_project.title",
description: "home.empty.create_project.description", description: "home.empty.create_project.description",
icon: <Briefcase className="w-[40px] h-[40px] text-custom-primary-100" />, icon: <Briefcase className="size-10" />,
flag: "projects",
cta: { cta: {
text: "home.empty.create_project.cta", text: "home.empty.create_project.cta",
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
@ -47,7 +60,8 @@ export const NoProjectsEmptyState = () => {
id: "invite-team", id: "invite-team",
title: "home.empty.invite_team.title", title: "home.empty.invite_team.title",
description: "home.empty.invite_team.description", description: "home.empty.invite_team.description",
icon: <Users className="w-[40px] h-[40px] text-custom-primary-100" />, icon: <Users className="size-10" />,
flag: "visited_members",
cta: { cta: {
text: "home.empty.invite_team.cta", text: "home.empty.invite_team.cta",
link: `/${workspaceSlug}/settings/members`, link: `/${workspaceSlug}/settings/members`,
@ -57,7 +71,8 @@ export const NoProjectsEmptyState = () => {
id: "configure-workspace", id: "configure-workspace",
title: "home.empty.configure_workspace.title", title: "home.empty.configure_workspace.title",
description: "home.empty.configure_workspace.description", description: "home.empty.configure_workspace.description",
icon: <Hotel className="w-[40px] h-[40px] text-custom-primary-100" />, icon: <Hotel className="size-10" />,
flag: "visited_workspace",
cta: { cta: {
text: "home.empty.configure_workspace.cta", text: "home.empty.configure_workspace.cta",
link: "settings", link: "settings",
@ -85,29 +100,77 @@ export const NoProjectsEmptyState = () => {
</span> </span>
</Link> </Link>
), ),
flag: "visited_profile",
cta: { cta: {
text: "home.empty.personalize_account.cta", text: "home.empty.personalize_account.cta",
link: "/profile", link: "/profile",
}, },
}, },
]; ];
const isComplete = (type: string) => {
switch (type) {
case "projects":
return joinedProjectIds?.length > 0;
case "visited_members":
return storedValue?.visited_members;
case "visited_workspace":
return storedValue?.visited_workspace;
case "visited_profile":
return storedValue?.visited_profile;
}
};
if (storedValue?.hide) return null;
return ( return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="text-base font-semibold text-custom-text-350">{t("home.empty.quickstart_guide")}</div>
<button
className="text-custom-text-300 font-medium text-sm flex items-center gap-1"
onClick={() => {
if (!storedValue) return;
setValue({ ...storedValue, hide: true });
}}
>
<X className="size-4" />
{t("home.empty.not_right_now")}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{EMPTY_STATE_DATA.map((item) => ( {EMPTY_STATE_DATA.map((item) => {
const isStateComplete = isComplete(item.flag);
return (
<div <div
key={item.id} key={item.id}
className="flex flex-col items-center justify-center p-6 bg-custom-background-100 rounded-lg text-center border border-custom-border-200/40" className="flex flex-col items-center justify-center p-6 bg-custom-background-100 rounded-lg text-center border border-custom-border-200/40"
> >
<div className="grid place-items-center bg-custom-primary-100/10 rounded-full size-24 mb-3"> <div
className={cn(
"grid place-items-center bg-custom-background-90 rounded-full size-20 mb-3 text-custom-text-400",
{
"text-custom-primary-100 bg-custom-primary-100/10": !isStateComplete,
}
)}
>
<span className="text-3xl my-auto">{item.icon}</span> <span className="text-3xl my-auto">{item.icon}</span>
</div> </div>
<h3 className="text-lg font-medium text-custom-text-100 mb-2">{t(item.title)}</h3> <h3 className="text-base font-medium text-custom-text-100 mb-2">{t(item.title)}</h3>
<p className="text-sm text-custom-text-200 mb-4 w-[80%] flex-1">{t(item.description)}</p> <p className="text-sm text-custom-text-300 mb-2">{t(item.description)}</p>
{isStateComplete ? (
{item.cta.link ? ( <div className="flex items-center gap-2 bg-[#17a34a] rounded-full p-1">
<Check className="size-3 text-custom-primary-100 text-white" />
</div>
) : item.cta.link ? (
<Link <Link
href={item.cta.link} href={item.cta.link}
onClick={() => {
if (!storedValue) return;
setValue({
...storedValue,
[item.flag]: true,
});
}}
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium" className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
> >
{t(item.cta.text)} {t(item.cta.text)}
@ -122,7 +185,9 @@ export const NoProjectsEmptyState = () => {
</button> </button>
)} )}
</div> </div>
))} );
})}
</div>
</div> </div>
); );
}; });

View file

@ -42,9 +42,9 @@ export const useLinks = (workspaceSlug: string) => {
}); });
toggleLinkModal(false); toggleLinkModal(false);
} catch (error: any) { } catch (error: any) {
console.error("error", error); console.error("error", error?.data?.url?.error);
setToast({ setToast({
message: error?.data?.error ?? t("links.toasts.not_created.message"), message: error?.data?.url?.error ?? t("links.toasts.not_created.message"),
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: t("links.toasts.not_created.title"), title: t("links.toasts.not_created.title"),
}); });

View file

@ -0,0 +1,22 @@
"use client";
import range from "lodash/range";
// ui
import { Loader } from "@plane/ui";
export const HomeLoader = () => (
<>
{range(3).map((index) => (
<div key={index}>
<div className="mb-2">
<div className="text-base font-semibold text-custom-text-350 mb-4">
<Loader.Item height="20px" width="100px" />
</div>
<Loader className="h-[110px] w-full flex items-center justify-center gap-2 text-custom-text-400 rounded">
<Loader.Item height="100%" width="100%" />
</Loader>
</div>
</div>
))}
</>
);

View file

@ -1 +1,2 @@
export * from "./loader"; export * from "./loader";
export * from "./home-loader";

View file

@ -2,7 +2,6 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
import { Briefcase, FileText } from "lucide-react"; import { Briefcase, FileText } from "lucide-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
@ -12,11 +11,9 @@ import { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from
import { LayersIcon } from "@plane/ui"; import { LayersIcon } from "@plane/ui";
// components // components
import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC"; import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC";
// hooks
import { useProject } from "@/hooks/store";
// plane web services // plane web services
import { WorkspaceService } from "@/plane-web/services"; import { WorkspaceService } from "@/plane-web/services";
import { NoProjectsEmptyState, RecentsEmptyState } from "../empty-states"; import { RecentsEmptyState } from "../empty-states";
import { EWidgetKeys, WidgetLoader } from "../loaders"; import { EWidgetKeys, WidgetLoader } from "../loaders";
import { FiltersDropdown } from "./filters"; import { FiltersDropdown } from "./filters";
import { RecentIssue } from "./issue"; import { RecentIssue } from "./issue";
@ -41,15 +38,9 @@ export const RecentActivityWidget: React.FC<TRecentWidgetProps> = observer((prop
const { presetFilter, showFilterSelect = true, workspaceSlug } = props; const { presetFilter, showFilterSelect = true, workspaceSlug } = props;
// states // states
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(presetFilter ?? filters[0].name); const [filter, setFilter] = useState<TRecentActivityFilterKeys>(presetFilter ?? filters[0].name);
// navigation const { t } = useTranslation();
const pathname = usePathname();
// ref // ref
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
// store hooks
const { joinedProjectIds, loader } = useProject();
const { t } = useTranslation();
// derived values
const isWikiApp = pathname.includes(`/${workspaceSlug.toString()}/pages`);
const { data: recents, isLoading } = useSWR( const { data: recents, isLoading } = useSWR(
workspaceSlug ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null, workspaceSlug ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null,
@ -81,8 +72,6 @@ export const RecentActivityWidget: React.FC<TRecentWidgetProps> = observer((prop
} }
}; };
if (loader === "loaded" && !isWikiApp && joinedProjectIds?.length === 0) return <NoProjectsEmptyState />;
if (!isLoading && recents?.length === 0) if (!isLoading && recents?.length === 0)
return ( return (
<div ref={ref} className="max-h-[500px] overflow-y-scroll"> <div ref={ref} className="max-h-[500px] overflow-y-scroll">

View file

@ -192,7 +192,7 @@ export const StickiesLayout = (props: TStickiesLayout) => {
const columnCount = getColumnCount(containerWidth); const columnCount = getColumnCount(containerWidth);
return ( return (
<div ref={ref} className="size-full min-h-[500px]"> <div ref={ref} className="size-full">
<StickiesList {...props} columnCount={columnCount} /> <StickiesList {...props} columnCount={columnCount} />
</div> </div>
); );

View file

@ -7,6 +7,7 @@ import { IWorkspaceLinkStore, WorkspaceLinkStore } from "./link.store";
export interface IHomeStore { export interface IHomeStore {
// observables // observables
loading: boolean;
showWidgetSettings: boolean; showWidgetSettings: boolean;
widgetsMap: Record<string, TWidgetEntityData>; widgetsMap: Record<string, TWidgetEntityData>;
widgets: THomeWidgetKeys[]; widgets: THomeWidgetKeys[];
@ -25,6 +26,7 @@ export interface IHomeStore {
export class HomeStore implements IHomeStore { export class HomeStore implements IHomeStore {
// observables // observables
showWidgetSettings = false; showWidgetSettings = false;
loading = false;
widgetsMap: Record<string, TWidgetEntityData> = {}; widgetsMap: Record<string, TWidgetEntityData> = {};
widgets: THomeWidgetKeys[] = []; widgets: THomeWidgetKeys[] = [];
// stores // stores
@ -35,6 +37,7 @@ export class HomeStore implements IHomeStore {
constructor() { constructor() {
makeObservable(this, { makeObservable(this, {
// observables // observables
loading: observable,
showWidgetSettings: observable, showWidgetSettings: observable,
widgetsMap: observable, widgetsMap: observable,
widgets: observable, widgets: observable,
@ -68,15 +71,18 @@ export class HomeStore implements IHomeStore {
fetchWidgets = async (workspaceSlug: string) => { fetchWidgets = async (workspaceSlug: string) => {
try { try {
this.loading = true;
const widgets = await this.workspaceService.fetchWorkspaceWidgets(workspaceSlug); const widgets = await this.workspaceService.fetchWorkspaceWidgets(workspaceSlug);
runInAction(() => { runInAction(() => {
this.widgets = orderBy(Object.values(widgets), "sort_order", "desc").map((widget) => widget.key); this.widgets = orderBy(Object.values(widgets), "sort_order", "desc").map((widget) => widget.key);
widgets.forEach((widget) => { widgets.forEach((widget) => {
this.widgetsMap[widget.key] = widget; this.widgetsMap[widget.key] = widget;
}); });
this.loading = false;
}); });
} catch (error) { } catch (error) {
console.error("Failed to fetch widgets"); console.error("Failed to fetch widgets");
this.loading = false;
throw error; throw error;
} }
}; };

View file

@ -93,7 +93,6 @@ export class WorkspaceLinkStore implements IWorkspaceLinkStore {
}; };
createLink = async (workspaceSlug: string, data: Partial<TLink>) => { createLink = async (workspaceSlug: string, data: Partial<TLink>) => {
console.log("hereee");
const response = await this.workspaceService.createWorkspaceLink(workspaceSlug, data); const response = await this.workspaceService.createWorkspaceLink(workspaceSlug, data);
runInAction(() => { runInAction(() => {