diff --git a/packages/types/src/home.d.ts b/packages/types/src/home.d.ts new file mode 100644 index 00000000000..c93fb448008 --- /dev/null +++ b/packages/types/src/home.d.ts @@ -0,0 +1,76 @@ +import { TLogoProps } from "./common"; +import { TIssuePriorities } from "./issues"; + +export type TRecentActivityFilterKeys = "all item" | "issue" | "page" | "project"; +export type THomeWidgetKeys = "quick_links" | "recents" | "my_stickies" | "quick_tutorial" | "new_at_plane"; + +export type THomeWidgetProps = { + workspaceSlug: string; +}; + +export type TPageEntityData = { + id: string; + name: string; + logo_props: TLogoProps; + project_id: string; + owned_by: string; + project_identifier: string; +}; + +export type TProjectEntityData = { + id: string; + name: string; + logo_props: TLogoProps; + project_members: string[]; + identifier: string; +}; + +export type TIssueEntityData = { + id: string; + name: string; + state: string; + priority: TIssuePriorities; + assignees: string[]; + type: string | null; + sequence_id: number; + project_id: string; + project_identifier: string; +}; + +export type TActivityEntityData = { + id: string; + entity_name: "page" | "project" | "issue"; + entity_identifier: string; + visited_at: string; + entity_data: TPageEntityData | TProjectEntityData | TIssueEntityData; +}; + +export type TLinkEditableFields = { + title: string; + url: string; +}; + +export type TLink = TLinkEditableFields & { + created_by_id: string; + id: string; + metadata: any; + workspace_slug: string; + + //need + created_at: Date; +}; + +export type TLinkMap = { + [workspace_slug: string]: TLink; +}; + +export type TLinkIdMap = { + [workspace_slug: string]: string[]; +}; + +export type TWidgetEntityData = { + key: THomeWidgetKeys; + name: string; + is_enabled: boolean; + sort_order: number; +}; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index cd55c1873f3..7a9cd8b3327 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -38,3 +38,4 @@ export * from "./timezone"; export * from "./activity"; export * from "./epics"; export * from "./charts"; +export * from "./home"; diff --git a/web/app/[workspaceSlug]/(projects)/home/header.tsx b/web/app/[workspaceSlug]/(projects)/home/header.tsx new file mode 100644 index 00000000000..a7a19afe6b0 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/home/header.tsx @@ -0,0 +1,60 @@ +"use client"; + +import Image from "next/image"; +import { useTheme } from "next-themes"; +import { Home } from "lucide-react"; +// images +import githubBlackImage from "/public/logos/github-black.png"; +import githubWhiteImage from "/public/logos/github-white.png"; +// ui +import { Breadcrumbs, Header } from "@plane/ui"; +// components +import { BreadcrumbLink } from "@/components/common"; +// constants +import { GITHUB_REDIRECTED } from "@/constants/event-tracker"; +// hooks +import { useEventTracker } from "@/hooks/store"; + +export const WorkspaceDashboardHeader = () => { + // hooks + const { captureEvent } = useEventTracker(); + const { resolvedTheme } = useTheme(); + + return ( + <> +
+ +
+ + } />} + /> + +
+
+ + + captureEvent(GITHUB_REDIRECTED, { + element: "navbar", + }) + } + className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5" + href="https://github.com/makeplane/plane" + target="_blank" + rel="noopener noreferrer" + > + GitHub Logo + Star us on GitHub + + +
+ + ); +}; diff --git a/web/app/[workspaceSlug]/(projects)/home/page.tsx b/web/app/[workspaceSlug]/(projects)/home/page.tsx new file mode 100644 index 00000000000..9f9f535e03d --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/home/page.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { PageHead, AppHeader, ContentWrapper } from "@/components/core"; +// hooks +import { WorkspaceHomeView } from "@/components/home"; +import { useWorkspace } from "@/hooks/store"; +// local components +import { WorkspaceDashboardHeader } from "../header"; + +const WorkspaceDashboardPage = observer(() => { + const { currentWorkspace } = useWorkspace(); + // derived values + const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Home` : undefined; + + return ( + <> + } /> + + + + + + ); +}); + +export default WorkspaceDashboardPage; diff --git a/web/ce/components/home/header.tsx b/web/ce/components/home/header.tsx new file mode 100644 index 00000000000..c95736c9ebd --- /dev/null +++ b/web/ce/components/home/header.tsx @@ -0,0 +1 @@ +export const HomePageHeader = () => <>; diff --git a/web/ce/components/stickies/index.ts b/web/ce/components/stickies/index.ts new file mode 100644 index 00000000000..97866ce19be --- /dev/null +++ b/web/ce/components/stickies/index.ts @@ -0,0 +1 @@ +export * from "./widget"; diff --git a/web/ce/components/stickies/widget.tsx b/web/ce/components/stickies/widget.tsx new file mode 100644 index 00000000000..56df281e1e4 --- /dev/null +++ b/web/ce/components/stickies/widget.tsx @@ -0,0 +1 @@ +export const StickiesWidget = () => <>; diff --git a/web/core/components/core/list/list-item.tsx b/web/core/components/core/list/list-item.tsx index 930f630cc04..223879334b8 100644 --- a/web/core/components/core/list/list-item.tsx +++ b/web/core/components/core/list/list-item.tsx @@ -18,6 +18,7 @@ interface IListItemProps { parentRef: React.RefObject; disableLink?: boolean; className?: string; + itemClassName?: string; actionItemContainerClassName?: string; isSidebarOpen?: boolean; quickActionElement?: JSX.Element; @@ -38,6 +39,7 @@ export const ListItem: FC = (props) => { actionItemContainerClassName = "", isSidebarOpen = false, quickActionElement, + itemClassName = "", } = props; // router @@ -61,7 +63,7 @@ export const ListItem: FC = (props) => { className )} > -
+
| null; fullWidth: boolean }; +} = { + quick_links: { component: DashboardQuickLinks, fullWidth: false }, + recents: { component: RecentActivityWidget, fullWidth: false }, + my_stickies: { component: StickiesWidget, fullWidth: false }, + new_at_plane: { component: null, fullWidth: false }, + quick_tutorial: { component: null, fullWidth: false }, +}; + +export const DashboardWidgets = observer(() => { + // router + const { workspaceSlug } = useParams(); + // store hooks + const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets } = useHome(); + + if (!workspaceSlug) return null; + + return ( +
+ + toggleWidgetSettings(false)} + /> + + {orderedWidgets.map((key) => { + const WidgetComponent = WIDGETS_LIST[key]?.component; + const isEnabled = widgetsMap[key]?.is_enabled; + if (!WidgetComponent || !isEnabled) return null; + if (WIDGETS_LIST[key]?.fullWidth) + return ( +
+ +
+ ); + else return ; + })} +
+ ); +}); diff --git a/web/core/components/home/index.ts b/web/core/components/home/index.ts new file mode 100644 index 00000000000..d783e089a0e --- /dev/null +++ b/web/core/components/home/index.ts @@ -0,0 +1,4 @@ +export * from "./widgets"; +export * from "./home-dashboard-widgets"; +export * from "./project-empty-state"; +export * from "./root"; diff --git a/web/core/components/home/project-empty-state.tsx b/web/core/components/home/project-empty-state.tsx new file mode 100644 index 00000000000..c8cba817a37 --- /dev/null +++ b/web/core/components/home/project-empty-state.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { observer } from "mobx-react"; +import Image from "next/image"; +// ui +import { Button } from "@plane/ui"; +// hooks +import { useCommandPalette, useEventTracker, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; +// assets +import ProjectEmptyStateImage from "@/public/empty-state/onboarding/dashboard-light.webp"; + +export const DashboardProjectEmptyState = observer(() => { + // store hooks + const { toggleCreateProjectModal } = useCommandPalette(); + const { setTrackElement } = useEventTracker(); + const { allowPermissions } = useUserPermissions(); + + // derived values + const canCreateProject = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + + return ( +
+

Overview of your projects, activity, and metrics

+

+ Welcome to Plane, we are excited to have you here. Create your first project and track your issues, and this + page will transform into a space that helps you progress. Admins will also see items which help their team + progress. +

+ Project empty state + {canCreateProject && ( +
+ +
+ )} +
+ ); +}); diff --git a/web/core/components/home/root.tsx b/web/core/components/home/root.tsx new file mode 100644 index 00000000000..44cd9356614 --- /dev/null +++ b/web/core/components/home/root.tsx @@ -0,0 +1,97 @@ +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// components +import useSWR from "swr"; +import { ContentWrapper } from "@plane/ui"; +import { EmptyState } from "@/components/empty-state"; +import { TourRoot } from "@/components/onboarding"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; +import { PRODUCT_TOUR_COMPLETED } from "@/constants/event-tracker"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useCommandPalette, useUserProfile, useEventTracker, useProject, useUser } from "@/hooks/store"; +import { useHome } from "@/hooks/store/use-home"; +import useSize from "@/hooks/use-window-size"; +import { IssuePeekOverview } from "../issues"; +import { DashboardWidgets } from "./home-dashboard-widgets"; +import { UserGreetingsView } from "./user-greetings"; + +export const WorkspaceHomeView = observer(() => { + // store hooks + const { + // captureEvent, + setTrackElement, + } = useEventTracker(); + const { toggleCreateProjectModal } = useCommandPalette(); + const { workspaceSlug } = useParams(); + const { data: currentUser } = useUser(); + const { data: currentUserProfile, updateTourCompleted } = useUserProfile(); + const { captureEvent } = useEventTracker(); + const { toggleWidgetSettings, fetchWidgets } = useHome(); + const { joinedProjectIds, loader } = useProject(); + const [windowWidth] = useSize(); + + useSWR( + workspaceSlug ? `HOME_DASHBOARD_WIDGETS_${workspaceSlug}` : null, + workspaceSlug ? () => fetchWidgets(workspaceSlug?.toString()) : null, + { + revalidateIfStale: true, + revalidateOnFocus: false, + revalidateOnReconnect: true, + } + ); + + const handleTourCompleted = () => { + updateTourCompleted() + .then(() => { + captureEvent(PRODUCT_TOUR_COMPLETED, { + user_id: currentUser?.id, + state: "SUCCESS", + }); + }) + .catch((error) => { + console.error(error); + }); + }; + + // TODO: refactor loader implementation + return ( + <> + {currentUserProfile && !currentUserProfile.is_tour_completed && ( +
+ +
+ )} + {joinedProjectIds && ( + <> + {joinedProjectIds.length > 0 || loader ? ( + <> + + = 768, + })} + > + {currentUser && ( + toggleWidgetSettings(true)} /> + )} + + + + + ) : ( + { + setTrackElement("Dashboard empty state"); + toggleCreateProjectModal(true); + }} + /> + )} + + )} + + ); +}); diff --git a/web/core/components/home/user-greetings.tsx b/web/core/components/home/user-greetings.tsx new file mode 100644 index 00000000000..d9e68880123 --- /dev/null +++ b/web/core/components/home/user-greetings.tsx @@ -0,0 +1,63 @@ +import { FC } from "react"; +// hooks +import { Shapes } from "lucide-react"; +import { IUser } from "@plane/types"; +import { useCurrentTime } from "@/hooks/use-current-time"; +// types + +export interface IUserGreetingsView { + user: IUser; + handleWidgetModal: () => void; +} + +export const UserGreetingsView: FC = (props) => { + const { user, handleWidgetModal } = props; + // current time hook + const { currentTime } = useCurrentTime(); + + const hour = new Intl.DateTimeFormat("en-US", { + hour12: false, + hour: "numeric", + }).format(currentTime); + + const date = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + }).format(currentTime); + + const weekDay = new Intl.DateTimeFormat("en-US", { + weekday: "long", + }).format(currentTime); + + const timeString = new Intl.DateTimeFormat("en-US", { + timeZone: user?.user_timezone, + hour12: false, // Use 24-hour format + hour: "2-digit", + minute: "2-digit", + }).format(currentTime); + + const greeting = parseInt(hour, 10) < 12 ? "morning" : parseInt(hour, 10) < 18 ? "afternoon" : "evening"; + + return ( +
+
+

+ Good {greeting}, {user?.first_name} {user?.last_name} +

+
+
{greeting === "morning" ? "🌤️" : greeting === "afternoon" ? "🌥️" : "🌙️"}
+
+ {weekDay}, {date} {timeString} +
+
+
+ +
+ ); +}; diff --git a/web/core/components/home/widgets/empty-states/index.ts b/web/core/components/home/widgets/empty-states/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/web/core/components/home/widgets/empty-states/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/core/components/home/widgets/empty-states/root.tsx b/web/core/components/home/widgets/empty-states/root.tsx new file mode 100644 index 00000000000..06606f367d0 --- /dev/null +++ b/web/core/components/home/widgets/empty-states/root.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { Briefcase, Hotel, Users } from "lucide-react"; +import { getFileURL } from "@/helpers/file.helper"; +import { useCommandPalette, useEventTracker, useUser, useUserPermissions } from "@/hooks/store"; +import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants"; + +export const EmptyWorkspace = () => { + const { workspaceSlug } = useParams(); + const { allowPermissions } = useUserPermissions(); + const { toggleCreateProjectModal } = useCommandPalette(); + const { setTrackElement } = useEventTracker(); + const { data: currentUser } = useUser(); + + const canCreateProject = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + + const EMPTY_STATE_DATA = [ + { + id: "create-project", + title: "Create a project", + description: "Create your first project now to get started", + icon: , + cta: { + text: "Create Project", + onClick: (e: React.MouseEvent) => { + if (!canCreateProject) return; + e.preventDefault(); + e.stopPropagation(); + setTrackElement("Sidebar"); + toggleCreateProjectModal(true); + }, + }, + }, + { + id: "invite-team", + title: "Invite your team", + description: "The sub text will be of two lines and that will be placed.", + icon: , + cta: { + text: "Invite now", + link: `/${workspaceSlug}/settings/members`, + }, + }, + { + id: "configure-workspace", + title: "Configure your workspace", + description: "The sub text will be of two lines and that will be placed.", + icon: , + cta: { + text: "Configure workspace", + link: "settings", + }, + }, + { + id: "personalize-account", + title: "Personalize your account", + description: "The sub text will be of two lines and that will be placed.", + icon: + currentUser?.avatar_url && currentUser?.avatar_url.trim() !== "" ? ( + + + {currentUser?.display_name + + + ) : ( + + + {(currentUser?.email ?? currentUser?.display_name ?? "?")[0]} + + + ), + cta: { + text: "Personalize account", + link: "/profile", + }, + }, + ]; + return ( +
+ {EMPTY_STATE_DATA.map((item) => ( +
+
+ {item.icon} +
+

{item.title}

+

{item.description}

+ + {item.cta.link ? ( + + {item.cta.text} + + ) : ( + + )} +
+ ))} +
+ ); +}; diff --git a/web/core/components/home/widgets/index.ts b/web/core/components/home/widgets/index.ts new file mode 100644 index 00000000000..19d9aba92f5 --- /dev/null +++ b/web/core/components/home/widgets/index.ts @@ -0,0 +1,4 @@ +export * from "./empty-states"; +export * from "./loaders"; +export * from "./recents"; +export * from "./empty-states"; diff --git a/web/core/components/home/widgets/links/action.tsx b/web/core/components/home/widgets/links/action.tsx new file mode 100644 index 00000000000..7d23f43ffcf --- /dev/null +++ b/web/core/components/home/widgets/links/action.tsx @@ -0,0 +1,20 @@ +import { PlusIcon } from "lucide-react"; + +type TProps = { + onClick: () => void; +}; +export const AddLink = (props: TProps) => { + const { onClick } = props; + + return ( + + ); +}; diff --git a/web/core/components/home/widgets/links/create-update-link-modal.tsx b/web/core/components/home/widgets/links/create-update-link-modal.tsx new file mode 100644 index 00000000000..0c25747842d --- /dev/null +++ b/web/core/components/home/widgets/links/create-update-link-modal.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { FC, useEffect } from "react"; +import { observer } from "mobx-react"; +import { Controller, useForm } from "react-hook-form"; +// plane types +// plane ui +import { TLink, TLinkEditableFields } from "@plane/types"; +import { Button, Input, ModalCore } from "@plane/ui"; +import { TLinkOperations } from "./use-links"; + +export type TLinkOperationsModal = Exclude; + +export type TLinkCreateFormFieldOptions = TLinkEditableFields & { + id?: string; +}; + +export type TLinkCreateEditModal = { + isModalOpen: boolean; + handleOnClose?: () => void; + linkOperations: TLinkOperationsModal; + preloadedData?: TLinkCreateFormFieldOptions; + setLinkData: (link: TLink | undefined) => void; +}; + +const defaultValues: TLinkCreateFormFieldOptions = { + title: "", + url: "", +}; + +export const LinkCreateUpdateModal: FC = observer((props) => { + // props + const { setLinkData, isModalOpen, handleOnClose, linkOperations, preloadedData } = props; + // react hook form + const { + formState: { errors, isSubmitting }, + handleSubmit, + control, + reset, + } = useForm({ + defaultValues, + }); + + const onClose = () => { + if (handleOnClose) handleOnClose(); + setLinkData(undefined); + }; + + const handleFormSubmit = async (formData: TLinkCreateFormFieldOptions) => { + const parsedUrl = formData.url.startsWith("http") ? formData.url : `http://${formData.url}`; + try { + if (!formData || !formData.id) await linkOperations.create({ title: formData.title, url: parsedUrl }); + else await linkOperations.update(formData.id, { title: formData.title, url: parsedUrl }); + onClose(); + } catch (error) { + console.error("error", error); + } + }; + + useEffect(() => { + if (isModalOpen) reset({ ...defaultValues, ...preloadedData }); + return () => reset(defaultValues); + }, [preloadedData, reset, isModalOpen]); + + return ( + +
+
+

+ {preloadedData?.id ? "Update" : "Add"} quick link +

+
+
+ + ( + + )} + /> + {errors.url && URL is invalid} +
+
+ + ( + + )} + /> +
+
+
+
+ + +
+
+
+ ); +}); diff --git a/web/core/components/home/widgets/links/index.ts b/web/core/components/home/widgets/links/index.ts new file mode 100644 index 00000000000..380f7763c16 --- /dev/null +++ b/web/core/components/home/widgets/links/index.ts @@ -0,0 +1,3 @@ +export * from "./root"; +export * from "./links"; +export * from "./link-detail"; diff --git a/web/core/components/home/widgets/links/link-detail.tsx b/web/core/components/home/widgets/links/link-detail.tsx new file mode 100644 index 00000000000..26aa4f9c8b4 --- /dev/null +++ b/web/core/components/home/widgets/links/link-detail.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { FC } from "react"; +// hooks +// ui +import { observer } from "mobx-react"; +import { Pencil, Trash2, ExternalLink, EllipsisVertical, Link, Link2 } from "lucide-react"; +import { TOAST_TYPE, setToast, CustomMenu, TContextMenuItem } from "@plane/ui"; +// helpers +import { cn } from "@plane/utils"; +import { calculateTimeAgo } from "@/helpers/date-time.helper"; +import { copyUrlToClipboard } from "@/helpers/string.helper"; +import { useHome } from "@/hooks/store/use-home"; +import { TLinkOperations } from "./use-links"; + +export type TProjectLinkDetail = { + linkId: string; + linkOperations: TLinkOperations; +}; + +export const ProjectLinkDetail: FC = observer((props) => { + // props + const { linkId, linkOperations } = props; + // hooks + const { + quickLinks: { getLinkById, toggleLinkModal, setLinkData }, + } = useHome(); + + const linkDetail = getLinkById(linkId); + if (!linkDetail) return <>; + + const viewLink = linkDetail.url; + + const handleEdit = (modalToggle: boolean) => { + toggleLinkModal(modalToggle); + setLinkData(linkDetail); + }; + + const handleCopyText = () => + copyUrlToClipboard(viewLink).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Link Copied!", + message: "View link copied to clipboard.", + }); + }); + const handleOpenInNewTab = () => window.open(`${viewLink}`, "_blank"); + + const MENU_ITEMS: TContextMenuItem[] = [ + { + key: "edit", + action: () => handleEdit(true), + title: "Edit", + icon: Pencil, + }, + { + key: "open-new-tab", + action: handleOpenInNewTab, + title: "Open in new tab", + icon: ExternalLink, + }, + { + key: "copy-link", + action: handleCopyText, + title: "Copy link", + icon: Link, + }, + { + key: "delete", + action: () => linkOperations.remove(linkId), + title: "Delete", + icon: Trash2, + }, + ]; + + return ( +
+
+ +
+
+
{linkDetail.title || linkDetail.url}
+
{calculateTimeAgo(linkDetail.created_at)}
+
+ + + } + placement="bottom-end" + menuItemsClassName="z-20" + closeOnSelect + className=" my-auto" + > + {MENU_ITEMS.map((item) => ( + { + e.preventDefault(); + e.stopPropagation(); + item.action(); + }} + className={cn("flex items-center gap-2 w-full ", { + "text-custom-text-400": item.disabled, + })} + disabled={item.disabled} + > + {item.icon && } +
+
{item.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ))} +
+
+ ); +}); diff --git a/web/core/components/home/widgets/links/links.tsx b/web/core/components/home/widgets/links/links.tsx new file mode 100644 index 00000000000..194b1dfc121 --- /dev/null +++ b/web/core/components/home/widgets/links/links.tsx @@ -0,0 +1,77 @@ +import { FC, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +// computed +import { useHome } from "@/hooks/store/use-home"; +import { EWidgetKeys, WidgetLoader } from "../loaders"; +import { AddLink } from "./action"; +import { ProjectLinkDetail } from "./link-detail"; +import { TLinkOperations } from "./use-links"; + +export type TLinkOperationsModal = Exclude; + +export type TProjectLinkList = { + linkOperations: TLinkOperationsModal; + workspaceSlug: string; +}; + +export const ProjectLinkList: FC = observer((props) => { + // props + const { linkOperations, workspaceSlug } = props; + // states + const [columnCount, setColumnCount] = useState(4); + const [showAll, setShowAll] = useState(false); + // hooks + const { + quickLinks: { getLinksByWorkspaceId, toggleLinkModal }, + } = useHome(); + + const links = getLinksByWorkspaceId(workspaceSlug); + + useEffect(() => { + const updateColumnCount = () => { + if (window.matchMedia("(min-width: 1024px)").matches) { + setColumnCount(4); // lg screens + } else if (window.matchMedia("(min-width: 768px)").matches) { + setColumnCount(3); // md screens + } else if (window.matchMedia("(min-width: 640px)").matches) { + setColumnCount(2); // sm screens + } else { + setColumnCount(1); // mobile + } + }; + + // Initial check + updateColumnCount(); + + // Add event listener for window resize + window.addEventListener("resize", updateColumnCount); + + // Cleanup + return () => window.removeEventListener("resize", updateColumnCount); + }, []); + + if (links === undefined) return ; + + return ( +
+
+ {links && + links.length > 0 && + (showAll ? links : links.slice(0, 2 * columnCount - 1)).map((linkId) => ( + + ))} + + {/* Add new link */} + toggleLinkModal(true)} /> +
+ {links.length > 2 * columnCount - 1 && ( + + )} +
+ ); +}); diff --git a/web/core/components/home/widgets/links/root.tsx b/web/core/components/home/widgets/links/root.tsx new file mode 100644 index 00000000000..b5d96678fc7 --- /dev/null +++ b/web/core/components/home/widgets/links/root.tsx @@ -0,0 +1,40 @@ +import { observer } from "mobx-react"; +import useSWR from "swr"; +import { THomeWidgetProps } from "@plane/types"; +import { useHome } from "@/hooks/store/use-home"; +import { LinkCreateUpdateModal } from "./create-update-link-modal"; +import { ProjectLinkList } from "./links"; +import { useLinks } from "./use-links"; + +export const DashboardQuickLinks = observer((props: THomeWidgetProps) => { + const { workspaceSlug } = props; + const { linkOperations } = useLinks(workspaceSlug); + const { + quickLinks: { isLinkModalOpen, toggleLinkModal, linkData, setLinkData, fetchLinks }, + } = useHome(); + + useSWR( + workspaceSlug ? `HOME_LINKS_${workspaceSlug}` : null, + workspaceSlug ? () => fetchLinks(workspaceSlug.toString()) : null, + { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); + return ( + <> + toggleLinkModal(false)} + linkOperations={linkOperations} + preloadedData={linkData} + setLinkData={setLinkData} + /> +
+ {/* rendering links */} + +
+ + ); +}); diff --git a/web/core/components/home/widgets/links/use-links.tsx b/web/core/components/home/widgets/links/use-links.tsx new file mode 100644 index 00000000000..fe107fd06a9 --- /dev/null +++ b/web/core/components/home/widgets/links/use-links.tsx @@ -0,0 +1,98 @@ +import { useMemo } from "react"; +import { TProjectLink } from "@plane/types"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { useHome } from "@/hooks/store/use-home"; + +export type TLinkOperations = { + create: (data: Partial) => Promise; + update: (linkId: string, data: Partial) => Promise; + remove: (linkId: string) => Promise; +}; +export type TProjectLinkRoot = { + workspaceSlug: string; +}; + +export const useLinks = (workspaceSlug: string) => { + // hooks + const { + quickLinks: { + createLink, + updateLink, + removeLink, + isLinkModalOpen, + toggleLinkModal, + linkData, + setLinkData, + fetchLinks, + }, + } = useHome(); + + const linkOperations: TLinkOperations = useMemo( + () => ({ + create: async (data: Partial) => { + try { + if (!workspaceSlug) throw new Error("Missing required fields"); + console.log("data", data, workspaceSlug); + await createLink(workspaceSlug, data); + setToast({ + message: "The link has been successfully created", + type: TOAST_TYPE.SUCCESS, + title: "Link created", + }); + toggleLinkModal(false); + } catch (error: any) { + console.error("error", error); + setToast({ + message: error?.data?.error ?? "The link could not be created", + type: TOAST_TYPE.ERROR, + title: "Link not created", + }); + throw error; + } + }, + update: async (linkId: string, data: Partial) => { + try { + if (!workspaceSlug) throw new Error("Missing required fields"); + await updateLink(workspaceSlug, linkId, data); + setToast({ + message: "The link has been successfully updated", + type: TOAST_TYPE.SUCCESS, + title: "Link updated", + }); + toggleLinkModal(false); + } catch (error) { + setToast({ + message: "The link could not be updated", + type: TOAST_TYPE.ERROR, + title: "Link not updated", + }); + throw error; + } + }, + remove: async (linkId: string) => { + try { + if (!workspaceSlug) throw new Error("Missing required fields"); + await removeLink(workspaceSlug, linkId); + setToast({ + message: "The link has been successfully removed", + type: TOAST_TYPE.SUCCESS, + title: "Link removed", + }); + } catch (error) { + setToast({ + message: "The link could not be removed", + type: TOAST_TYPE.ERROR, + title: "Link not removed", + }); + } + }, + }), + [workspaceSlug] + ); + + const handleOnClose = () => { + toggleLinkModal(false); + }; + + return { linkOperations, handleOnClose, isLinkModalOpen, toggleLinkModal, linkData, setLinkData, fetchLinks }; +}; diff --git a/web/core/components/home/widgets/loaders/index.ts b/web/core/components/home/widgets/loaders/index.ts new file mode 100644 index 00000000000..ee5286f0fbf --- /dev/null +++ b/web/core/components/home/widgets/loaders/index.ts @@ -0,0 +1 @@ +export * from "./loader"; diff --git a/web/core/components/home/widgets/loaders/loader.tsx b/web/core/components/home/widgets/loaders/loader.tsx new file mode 100644 index 00000000000..9fed9cafaaf --- /dev/null +++ b/web/core/components/home/widgets/loaders/loader.tsx @@ -0,0 +1,25 @@ +// components +import { QuickLinksWidgetLoader } from "./quick-links"; +import { RecentActivityWidgetLoader } from "./recent-activity"; + +// types + +type Props = { + widgetKey: EWidgetKeys; +}; + +export enum EWidgetKeys { + RECENT_ACTIVITY = "recent_activity", + QUICK_LINKS = "quick_links", +} + +export const WidgetLoader: React.FC = (props) => { + const { widgetKey } = props; + + const loaders = { + [EWidgetKeys.RECENT_ACTIVITY]: , + [EWidgetKeys.QUICK_LINKS]: , + }; + + return loaders[widgetKey]; +}; diff --git a/web/core/components/home/widgets/loaders/quick-links.tsx b/web/core/components/home/widgets/loaders/quick-links.tsx new file mode 100644 index 00000000000..e4b8deafcb8 --- /dev/null +++ b/web/core/components/home/widgets/loaders/quick-links.tsx @@ -0,0 +1,13 @@ +"use client"; + +import range from "lodash/range"; +// ui +import { Loader } from "@plane/ui"; + +export const QuickLinksWidgetLoader = () => ( + + {range(4).map((index) => ( + + ))} + +); diff --git a/web/core/components/home/widgets/loaders/recent-activity.tsx b/web/core/components/home/widgets/loaders/recent-activity.tsx new file mode 100644 index 00000000000..2f78db64a0a --- /dev/null +++ b/web/core/components/home/widgets/loaders/recent-activity.tsx @@ -0,0 +1,20 @@ +"use client"; + +import range from "lodash/range"; +// ui +import { Loader } from "@plane/ui"; + +export const RecentActivityWidgetLoader = () => ( + + {range(7).map((index) => ( +
+
+ +
+
+ +
+
+ ))} +
+); diff --git a/web/core/components/home/widgets/manage/index.tsx b/web/core/components/home/widgets/manage/index.tsx new file mode 100644 index 00000000000..670212c6f59 --- /dev/null +++ b/web/core/components/home/widgets/manage/index.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +// plane types +// plane ui +import { Button, EModalWidth, ModalCore } from "@plane/ui"; +import { WidgetList } from "./widget-list"; + +export type TProps = { + workspaceSlug: string; + isModalOpen: boolean; + handleOnClose?: () => void; +}; + +export const ManageWidgetsModal: FC = observer((props) => { + // props + const { workspaceSlug, isModalOpen, handleOnClose } = props; + + return ( + +
+
Manage widgets
+ +
+ + +
+
+
+ ); +}); diff --git a/web/core/components/home/widgets/manage/widget-item-drag-handle.tsx b/web/core/components/home/widgets/manage/widget-item-drag-handle.tsx new file mode 100644 index 00000000000..2995ce08651 --- /dev/null +++ b/web/core/components/home/widgets/manage/widget-item-drag-handle.tsx @@ -0,0 +1,26 @@ +"use client"; +import React, { FC } from "react"; +import { observer } from "mobx-react"; +// ui +import { DragHandle } from "@plane/ui"; +// helper +import { cn } from "@/helpers/common.helper"; + +type Props = { + sort_order: number | null; + isDragging: boolean; +}; + +export const WidgetItemDragHandle: FC = observer((props) => { + const { isDragging } = props; + + return ( +
+ +
+ ); +}); diff --git a/web/core/components/home/widgets/manage/widget-item.tsx b/web/core/components/home/widgets/manage/widget-item.tsx new file mode 100644 index 00000000000..453a96588c0 --- /dev/null +++ b/web/core/components/home/widgets/manage/widget-item.tsx @@ -0,0 +1,140 @@ +"use client"; + +import React, { FC, useEffect, useRef, useState } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { DropTargetRecord, DragLocationHistory } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types"; +import { + draggable, + dropTargetForElements, + ElementDragPayload, +} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview"; +import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"; +import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; + +import { observer } from "mobx-react"; +// plane helpers +import { useParams } from "next/navigation"; +import { createRoot } from "react-dom/client"; +// ui +import { InstructionType, TWidgetEntityData } from "@plane/types"; +// components +import { DropIndicator, ToggleSwitch } from "@plane/ui"; +// helpers +import { cn } from "@plane/utils"; +import { useHome } from "@/hooks/store/use-home"; +import { WidgetItemDragHandle } from "./widget-item-drag-handle"; +import { getCanDrop, getInstructionFromPayload } from "./widget.helpers"; + +type Props = { + widgetId: string; + isLastChild: boolean; + handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void; + handleToggle: (workspaceSlug: string, widgetKey: string, is_enabled: boolean) => void; +}; + +export const WidgetItem: FC = observer((props) => { + // props + const { widgetId, isLastChild, handleDrop, handleToggle } = props; + const { workspaceSlug } = useParams(); + //state + const [isDragging, setIsDragging] = useState(false); + const [instruction, setInstruction] = useState(undefined); + //ref + const elementRef = useRef(null); + // hooks + const { widgetsMap } = useHome(); + // derived values + const widget = widgetsMap[widgetId] as TWidgetEntityData; + + // drag and drop + useEffect(() => { + const element = elementRef.current; + + if (!element) return; + const initialData = { id: widget.key, isGroup: false }; + return combine( + draggable({ + element, + dragHandle: elementRef.current, + getInitialData: () => initialData, + onDragStart: () => { + setIsDragging(true); + }, + onDrop: () => { + setIsDragging(false); + }, + onGenerateDragPreview: ({ nativeSetDragImage }) => { + setCustomNativeDragPreview({ + getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }), + render: ({ container }) => { + const root = createRoot(container); + root.render(
{widget.key}
); + return () => root.unmount(); + }, + nativeSetDragImage, + }); + }, + }), + dropTargetForElements({ + element, + canDrop: ({ source }) => getCanDrop(source, widget), + onDragStart: () => { + setIsDragging(true); + }, + getData: ({ input, element }) => { + const blockedStates: InstructionType[] = ["make-child"]; + if (!isLastChild) { + blockedStates.push("reorder-below"); + } + + return attachInstruction(initialData, { + input, + element, + currentLevel: 1, + indentPerLevel: 0, + mode: isLastChild ? "last-in-group" : "standard", + block: blockedStates, + }); + }, + onDrag: ({ self, source, location }) => { + const instruction = getInstructionFromPayload(self, source, location); + setInstruction(instruction); + }, + onDragLeave: () => { + setInstruction(undefined); + }, + onDrop: ({ self, source, location }) => { + setInstruction(undefined); + handleDrop(self, source, location); + }, + }) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elementRef?.current, isDragging, isLastChild, widget.key]); + + return ( +
+ +
+
+ +
{widget.key.replaceAll("_", " ")}
+
+ handleToggle(workspaceSlug.toString(), widget.key, !widget.is_enabled)} + /> +
+ {isLastChild && } +
+ ); +}); diff --git a/web/core/components/home/widgets/manage/widget-list.tsx b/web/core/components/home/widgets/manage/widget-list.tsx new file mode 100644 index 00000000000..526d51ba913 --- /dev/null +++ b/web/core/components/home/widgets/manage/widget-list.tsx @@ -0,0 +1,60 @@ +import { + DragLocationHistory, + DropTargetRecord, + ElementDragPayload, +} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types"; +import { observer } from "mobx-react"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +import { useHome } from "@/hooks/store/use-home"; +import { WidgetItem } from "./widget-item"; +import { getInstructionFromPayload, TargetData } from "./widget.helpers"; + +export const WidgetList = observer(({ workspaceSlug }: { workspaceSlug: string }) => { + const { orderedWidgets, reorderWidget, toggleWidget } = useHome(); + + const handleDrop = (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => { + const dropTargets = location?.current?.dropTargets ?? []; + if (!dropTargets || dropTargets.length <= 0) return; + const dropTarget = + dropTargets.length > 1 ? dropTargets.find((target: DropTargetRecord) => target?.data?.isChild) : dropTargets[0]; + + const dropTargetData = dropTarget?.data as TargetData; + + if (!dropTarget || !dropTargetData) return; + const instruction = getInstructionFromPayload(dropTarget, source, location); + const droppedId = dropTargetData.id; + const sourceData = source.data as TargetData; + + if (!sourceData.id) return; + if (droppedId) { + try { + reorderWidget(workspaceSlug, sourceData.id, droppedId, instruction); /** sequence */ + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Widget reordered successfully.", + }); + } catch { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Error occurred while reordering widget.", + }); + } + } + }; + + return ( +
+ {orderedWidgets.map((widget, index) => ( + + ))} +
+ ); +}); diff --git a/web/core/components/home/widgets/manage/widget.helpers.ts b/web/core/components/home/widgets/manage/widget.helpers.ts new file mode 100644 index 00000000000..a72ee8028ae --- /dev/null +++ b/web/core/components/home/widgets/manage/widget.helpers.ts @@ -0,0 +1,62 @@ +import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; +import { InstructionType, IPragmaticPayloadLocation, TDropTarget, TWidgetEntityData } from "@plane/types"; + +export type TargetData = { + id: string; + parentId: string | null; + isGroup: boolean; + isChild: boolean; +}; + +/** + * extracts the Payload and translates the instruction for the current dropTarget based on drag and drop payload + * @param dropTarget dropTarget for which the instruction is required + * @param source the dragging widget data that is being dragged on the dropTarget + * @param location location includes the data of all the dropTargets the source is being dragged on + * @returns Instruction for dropTarget + */ +export const getInstructionFromPayload = ( + dropTarget: TDropTarget, + source: TDropTarget, + location: IPragmaticPayloadLocation +): InstructionType | undefined => { + const dropTargetData = dropTarget?.data as TargetData; + const sourceData = source?.data as TargetData; + const allDropTargets = location?.current?.dropTargets; + + // if all the dropTargets are greater than 1 meaning the source is being dragged on a group and its child at the same time + // and also if the dropTarget in question is also a group then, it should be a child of the current Droptarget + if (allDropTargets?.length > 1 && dropTargetData?.isGroup) return "make-child"; + + if (!dropTargetData || !sourceData) return undefined; + + let instruction = extractInstruction(dropTargetData)?.type; + + // If the instruction is blocked then set an instruction based on if dropTarget it is a child or not + if (instruction === "instruction-blocked") { + instruction = dropTargetData.isChild ? "reorder-above" : "make-child"; + } + + // if source that is being dragged is a group. A group cannon be a child of any other widget, + // hence if current instruction is to be a child of dropTarget then reorder-above instead + if (instruction === "make-child" && sourceData.isGroup) instruction = "reorder-above"; + + return instruction; +}; + +/** + * This provides a boolean to indicate if the widget can be dropped onto the droptarget + * @param source + * @param widget + * @returns + */ +export const getCanDrop = (source: TDropTarget, widget: TWidgetEntityData | undefined) => { + const sourceData = source?.data; + + if (!sourceData) return false; + + // a widget cannot be dropped on to itself + if (sourceData.id === widget?.key) return false; + + return true; +}; diff --git a/web/core/components/home/widgets/recents/filters.tsx b/web/core/components/home/widgets/recents/filters.tsx new file mode 100644 index 00000000000..da7e9e39ab3 --- /dev/null +++ b/web/core/components/home/widgets/recents/filters.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { ChevronDown } from "lucide-react"; +import { TRecentActivityFilterKeys } from "@plane/types"; +import { CustomMenu } from "@plane/ui"; +import { cn } from "@plane/utils"; + +export type TFiltersDropdown = { + className?: string; + activeFilter: TRecentActivityFilterKeys; + setActiveFilter: (filter: TRecentActivityFilterKeys) => void; + filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode }[]; +}; + +export const FiltersDropdown: FC = observer((props) => { + const { className, activeFilter, setActiveFilter, filters } = props; + + const DropdownOptions = () => + filters?.map((filter) => ( + { + setActiveFilter(filter.name); + }} + > + {filter.icon &&
{filter.icon}
} +
{`${filter.name}s`}
+
+ )); + + return ( + + {activeFilter && `${activeFilter}s`} + + + } + customButtonClassName="flex justify-center" + closeOnSelect + > + + + ); +}); diff --git a/web/core/components/home/widgets/recents/index.tsx b/web/core/components/home/widgets/recents/index.tsx new file mode 100644 index 00000000000..4db8633961f --- /dev/null +++ b/web/core/components/home/widgets/recents/index.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useRef, useState } from "react"; +import { observer } from "mobx-react"; +// types +import useSWR from "swr"; +import { Briefcase, FileText } from "lucide-react"; +import { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types"; +// components +import { LayersIcon } from "@plane/ui"; +import { WorkspaceService } from "@/plane-web/services"; +import { EmptyWorkspace } from "../empty-states"; +import { EWidgetKeys, WidgetLoader } from "../loaders"; +import { FiltersDropdown } from "./filters"; +import { RecentIssue } from "./issue"; +import { RecentPage } from "./page"; +import { RecentProject } from "./project"; + +const WIDGET_KEY = EWidgetKeys.RECENT_ACTIVITY; +const workspaceService = new WorkspaceService(); +const filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode }[] = [ + { name: "all item" }, + { name: "issue", icon: }, + { name: "page", icon: }, + { name: "project", icon: }, +]; + +export const RecentActivityWidget: React.FC = observer((props) => { + const { workspaceSlug } = props; + // state + const [filter, setFilter] = useState(filters[0].name); + // ref + const ref = useRef(null); + + const { data: recents, isLoading } = useSWR( + workspaceSlug ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null, + workspaceSlug + ? () => + workspaceService.fetchWorkspaceRecents( + workspaceSlug.toString(), + filter === filters[0].name ? undefined : filter + ) + : null, + { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); + + const resolveRecent = (activity: TActivityEntityData) => { + switch (activity.entity_name) { + case "page": + return ; + case "project": + return ; + case "issue": + return ; + default: + return <>; + } + }; + + if (!isLoading && recents?.length === 0) return ; + + return ( +
+
+
Recents
+ + +
+ {isLoading && } + {!isLoading && + recents?.length > 0 && + recents.map((activity: TActivityEntityData) =>
{resolveRecent(activity)}
)} +
+ ); +}); diff --git a/web/core/components/home/widgets/recents/issue.tsx b/web/core/components/home/widgets/recents/issue.tsx new file mode 100644 index 00000000000..c5f76b09303 --- /dev/null +++ b/web/core/components/home/widgets/recents/issue.tsx @@ -0,0 +1,83 @@ +import { TActivityEntityData, TIssueEntityData } from "@plane/types"; +import { PriorityIcon, StateGroupIcon, Tooltip } from "@plane/ui"; +import { ListItem } from "@/components/core/list"; +import { MemberDropdown } from "@/components/dropdowns"; +import { calculateTimeAgo } from "@/helpers/date-time.helper"; +import { useIssueDetail, useProjectState } from "@/hooks/store"; +import { IssueIdentifier } from "@/plane-web/components/issues"; + +type BlockProps = { + activity: TActivityEntityData; + ref: React.RefObject; + workspaceSlug: string; +}; +export const RecentIssue = (props: BlockProps) => { + const { activity, ref, workspaceSlug } = props; + // hooks + const { getStateById } = useProjectState(); + const { setPeekIssue } = useIssueDetail(); + // derived values + const issueDetails: TIssueEntityData = activity.entity_data as TIssueEntityData; + const state = getStateById(issueDetails?.state); + + return ( + + +
{issueDetails?.name}
+
{calculateTimeAgo(activity.visited_at)}
+
+ } + quickActionElement={ +
+ +
+ +
+
+ +
+ +
+
+ {issueDetails?.assignees?.length > 0 && ( +
+ {}} + disabled + multiple + buttonVariant={issueDetails?.assignees?.length > 0 ? "transparent-without-text" : "border-without-text"} + buttonClassName={issueDetails?.assignees?.length > 0 ? "hover:bg-transparent px-0" : ""} + showTooltip={issueDetails?.assignees?.length === 0} + placeholder="Assignees" + optionsClassName="z-10" + tooltipContent="" + /> +
+ )} +
+ } + parentRef={ref} + disableLink={false} + className="bg-transparent my-auto !px-2 border-none py-3" + itemClassName="my-auto" + onItemClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setPeekIssue({ workspaceSlug, projectId: issueDetails?.project_id, issueId: activity.entity_data.id }); + }} + /> + ); +}; diff --git a/web/core/components/home/widgets/recents/page.tsx b/web/core/components/home/widgets/recents/page.tsx new file mode 100644 index 00000000000..91a350c9e89 --- /dev/null +++ b/web/core/components/home/widgets/recents/page.tsx @@ -0,0 +1,63 @@ +import { useRouter } from "next/navigation"; +import { FileText } from "lucide-react"; +import { TActivityEntityData, TPageEntityData } from "@plane/types"; +import { Avatar, Logo } from "@plane/ui"; +import { getFileURL } from "@plane/utils"; +import { ListItem } from "@/components/core/list"; +import { calculateTimeAgo } from "@/helpers/date-time.helper"; +import { useMember } from "@/hooks/store"; + +type BlockProps = { + activity: TActivityEntityData; + ref: React.RefObject; + workspaceSlug: string; +}; +export const RecentPage = (props: BlockProps) => { + const { activity, ref, workspaceSlug } = props; + // router + const router = useRouter(); + // hooks + const { getUserDetails } = useMember(); + // derived values + const pageDetails: TPageEntityData = activity.entity_data as TPageEntityData; + const ownerDetails = getUserDetails(pageDetails?.owned_by); + return ( + +
+ <> + {pageDetails?.logo_props?.in_use ? ( + + ) : ( + + )} + +
+
+ {pageDetails?.project_identifier} +
+
{pageDetails?.name}
+
{calculateTimeAgo(activity.visited_at)}
+
+ } + quickActionElement={ +
+ +
+ } + parentRef={ref} + disableLink={false} + className="bg-transparent my-auto !px-2 border-none py-3" + itemClassName="my-auto" + onItemClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + router.push(`/${workspaceSlug}/projects/${pageDetails?.project_id}/pages/${pageDetails.id}`); + }} + /> + ); +}; diff --git a/web/core/components/home/widgets/recents/project.tsx b/web/core/components/home/widgets/recents/project.tsx new file mode 100644 index 00000000000..bfe2bd5cd1d --- /dev/null +++ b/web/core/components/home/widgets/recents/project.tsx @@ -0,0 +1,71 @@ +import { useRouter } from "next/navigation"; +import { TActivityEntityData, TProjectEntityData } from "@plane/types"; +import { Logo } from "@plane/ui"; +import { ListItem } from "@/components/core/list"; +import { MemberDropdown } from "@/components/dropdowns"; +import { calculateTimeAgo } from "@/helpers/date-time.helper"; + +type BlockProps = { + activity: TActivityEntityData; + ref: React.RefObject; + workspaceSlug: string; +}; +export const RecentProject = (props: BlockProps) => { + const { activity, ref, workspaceSlug } = props; + // router + const router = useRouter(); + // derived values + const projectDetails: TProjectEntityData = activity.entity_data as TProjectEntityData; + + return ( + +
+ +
+
+ {projectDetails?.identifier} +
+
{projectDetails?.name}
+
{calculateTimeAgo(activity.visited_at)}
+ + } + quickActionElement={ +
+ {projectDetails?.project_members?.length > 0 && ( +
+ {}} + disabled + multiple + buttonVariant={ + projectDetails?.project_members?.length > 0 ? "transparent-without-text" : "border-without-text" + } + buttonClassName={projectDetails?.project_members?.length > 0 ? "hover:bg-transparent px-0" : ""} + showTooltip={projectDetails?.project_members?.length === 0} + placeholder="Assignees" + optionsClassName="z-10" + tooltipContent="" + /> +
+ )} +
+ } + parentRef={ref} + disableLink={false} + className="bg-transparent my-auto !px-2 border-none py-3" + itemClassName="my-auto" + onItemClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + router.push(`/${workspaceSlug}/projects/${projectDetails?.id}/issues`); + }} + /> + ); +}; diff --git a/web/core/components/page-views/workspace-dashboard.tsx b/web/core/components/page-views/workspace-dashboard.tsx index 05546af56fc..f4df757cd8b 100644 --- a/web/core/components/page-views/workspace-dashboard.tsx +++ b/web/core/components/page-views/workspace-dashboard.tsx @@ -67,7 +67,7 @@ export const WorkspaceDashboardView = observer(() => { <> = 768, })} > diff --git a/web/core/hooks/store/use-home.ts b/web/core/hooks/store/use-home.ts new file mode 100644 index 00000000000..9ada6ddbd63 --- /dev/null +++ b/web/core/hooks/store/use-home.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// mobx store +import { StoreContext } from "@/lib/store-context"; +// types +import { IHomeStore } from "@/store/workspace/home"; + +export const useHome = (): IHomeStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useDashboard must be used within StoreProvider"); + return context.workspaceRoot.home; +}; diff --git a/web/core/hooks/use-workspace-issue-properties.ts b/web/core/hooks/use-workspace-issue-properties.ts index 4cad3a2df38..a3da0295995 100644 --- a/web/core/hooks/use-workspace-issue-properties.ts +++ b/web/core/hooks/use-workspace-issue-properties.ts @@ -4,8 +4,6 @@ import { useCycle, useProjectEstimates, useLabel, useModule, useProjectState } f export const useWorkspaceIssueProperties = (workspaceSlug: string | string[] | undefined) => { const { fetchWorkspaceLabels } = useLabel(); - const { fetchWorkspaceStates } = useProjectState(); - const { getWorkspaceEstimates } = useProjectEstimates(); const { fetchWorkspaceModules } = useModule(); @@ -33,13 +31,6 @@ export const useWorkspaceIssueProperties = (workspaceSlug: string | string[] | u { revalidateIfStale: false, revalidateOnFocus: false } ); - // fetch workspace states - useSWR( - workspaceSlug ? `WORKSPACE_STATES_${workspaceSlug}` : null, - workspaceSlug ? () => fetchWorkspaceStates(workspaceSlug.toString()) : null, - { revalidateIfStale: false, revalidateOnFocus: false } - ); - // fetch workspace estimates useSWR( workspaceSlug ? `WORKSPACE_ESTIMATES_${workspaceSlug}` : null, diff --git a/web/core/layouts/auth-layout/workspace-wrapper.tsx b/web/core/layouts/auth-layout/workspace-wrapper.tsx index ab39dd76147..87588008ea2 100644 --- a/web/core/layouts/auth-layout/workspace-wrapper.tsx +++ b/web/core/layouts/auth-layout/workspace-wrapper.tsx @@ -14,7 +14,7 @@ import { Button, setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common"; // hooks -import { useMember, useProject, useUser, useUserPermissions, useWorkspace } from "@/hooks/store"; +import { useMember, useProject, useProjectState, useUser, useUserPermissions, useWorkspace } from "@/hooks/store"; import { useFavorite } from "@/hooks/store/use-favorite"; import { usePlatformOS } from "@/hooks/use-platform-os"; // local @@ -48,6 +48,7 @@ export const WorkspaceAuthWrapper: FC = observer((props) const { isMobile } = usePlatformOS(); const { loader, workspaceInfoBySlug, fetchUserWorkspaceInfo, fetchUserProjectPermissions, allowPermissions } = useUserPermissions(); + const { fetchWorkspaceStates } = useProjectState(); // derived values const canPerformWorkspaceMemberActions = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], @@ -93,6 +94,12 @@ export const WorkspaceAuthWrapper: FC = observer((props) : null, { revalidateIfStale: false, revalidateOnFocus: false } ); + // fetch workspace states + useSWR( + workspaceSlug ? `WORKSPACE_STATES_${workspaceSlug}` : null, + workspaceSlug ? () => fetchWorkspaceStates(workspaceSlug.toString()) : null, + { revalidateIfStale: false, revalidateOnFocus: false } + ); // initialize the local database const { isLoading: isDBInitializing } = useSWRImmutable( diff --git a/web/core/services/workspace.service.ts b/web/core/services/workspace.service.ts index 6fc0d21b429..a4d2e7fdcc7 100644 --- a/web/core/services/workspace.service.ts +++ b/web/core/services/workspace.service.ts @@ -12,8 +12,10 @@ import { IUserProjectsRole, IWorkspaceView, TIssuesResponse, + TLink, TSearchResponse, TSearchEntityRequestPayload, + TWidgetEntityData, } from "@plane/types"; import { APIService } from "@/services/api.service"; // helpers @@ -280,6 +282,39 @@ export class WorkspaceService extends APIService { }); } + // quick links + async fetchWorkspaceLinks(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/quick-links/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async createWorkspaceLink(workspaceSlug: string, data: Partial): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/quick-links/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async updateWorkspaceLink(workspaceSlug: string, linkId: string, data: Partial): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/quick-links/${linkId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async deleteWorkspaceLink(workspaceSlug: string, linkId: string): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/quick-links/${linkId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async searchEntity(workspaceSlug: string, params: TSearchEntityRequestPayload): Promise { return this.get(`/api/workspaces/${workspaceSlug}/entity-search/`, { params: { @@ -292,4 +327,38 @@ export class WorkspaceService extends APIService { throw error?.response?.data; }); } + + // recents + async fetchWorkspaceRecents(workspaceSlug: string, entity_name?: string) { + return this.get(`/api/workspaces/${workspaceSlug}/recent-visits/`, { + params: { + entity_name, + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + // widgets + async fetchWorkspaceWidgets(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/home-preferences/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + + async updateWorkspaceWidget( + workspaceSlug: string, + widgetKey: string, + data: Partial + ): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/home-preferences/${widgetKey}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } } diff --git a/web/core/store/workspace/home.ts b/web/core/store/workspace/home.ts new file mode 100644 index 00000000000..137e53fb004 --- /dev/null +++ b/web/core/store/workspace/home.ts @@ -0,0 +1,126 @@ +import orderBy from "lodash/orderBy"; +import set from "lodash/set"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { THomeWidgetKeys, TWidgetEntityData } from "@plane/types"; +import { WorkspaceService } from "@/plane-web/services"; +import { IWorkspaceLinkStore, WorkspaceLinkStore } from "./link.store"; + +export interface IHomeStore { + // observables + showWidgetSettings: boolean; + widgetsMap: Record; + widgets: THomeWidgetKeys[]; + // computed + orderedWidgets: THomeWidgetKeys[]; + //stores + quickLinks: IWorkspaceLinkStore; + // actions + toggleWidgetSettings: (value?: boolean) => void; + fetchWidgets: (workspaceSlug: string) => Promise; + reorderWidget: (workspaceSlug: string, widgetKey: string, destinationId: string, edge: string | undefined) => void; + toggleWidget: (workspaceSlug: string, widgetKey: string, is_enabled: boolean) => void; +} + +export class HomeStore implements IHomeStore { + // observables + showWidgetSettings = false; + widgetsMap: Record = {}; + widgets: THomeWidgetKeys[] = []; + // stores + quickLinks: IWorkspaceLinkStore; + // services + workspaceService: WorkspaceService; + + constructor() { + makeObservable(this, { + // observables + showWidgetSettings: observable, + widgetsMap: observable, + widgets: observable, + // computed + orderedWidgets: computed, + // actions + toggleWidgetSettings: action, + fetchWidgets: action, + reorderWidget: action, + toggleWidget: action, + }); + // services + this.workspaceService = new WorkspaceService(); + + // stores + this.quickLinks = new WorkspaceLinkStore(); + } + + get orderedWidgets() { + return orderBy(Object.values(this.widgetsMap), "sort_order", "desc").map((widget) => widget.key); + } + + toggleWidgetSettings = (value?: boolean) => { + this.showWidgetSettings = value !== undefined ? value : !this.showWidgetSettings; + }; + + fetchWidgets = async (workspaceSlug: string) => { + try { + const widgets = await this.workspaceService.fetchWorkspaceWidgets(workspaceSlug); + runInAction(() => { + this.widgets = orderBy(Object.values(widgets), "sort_order", "desc").map((widget) => widget.key); + widgets.forEach((widget) => { + this.widgetsMap[widget.key] = widget; + }); + }); + } catch (error) { + console.error("Failed to fetch widgets"); + throw error; + } + }; + + toggleWidget = async (workspaceSlug: string, widgetKey: string, is_enabled: boolean) => { + try { + await this.workspaceService.updateWorkspaceWidget(workspaceSlug, widgetKey, { + is_enabled, + }); + runInAction(() => { + this.widgetsMap[widgetKey].is_enabled = is_enabled; + }); + } catch (error) { + console.error("Failed to toggle widget"); + throw error; + } + }; + + reorderWidget = async (workspaceSlug: string, widgetKey: string, destinationId: string, edge: string | undefined) => { + console.log("edge", edge); + try { + let resultSequence = 10000; + console.log("edge", edge); + if (edge) { + const sortedIds = orderBy(Object.values(this.widgetsMap), "sort_order", "desc").map((widget) => widget.key); + const destinationSequence = this.widgetsMap[destinationId]?.sort_order || undefined; + console.log("destinationSequence", destinationSequence); + if (destinationSequence) { + const destinationIndex = sortedIds.findIndex((id) => id === destinationId); + if (edge === "reorder-above") { + const prevSequence = this.widgetsMap[sortedIds[destinationIndex - 1]]?.sort_order || undefined; + if (prevSequence) { + resultSequence = (destinationSequence + prevSequence) / 2; + } else { + resultSequence = destinationSequence + resultSequence; + } + } else { + resultSequence = destinationSequence - resultSequence; + } + } + } + await this.workspaceService.updateWorkspaceWidget(workspaceSlug, widgetKey, { + sort_order: resultSequence, + }); + runInAction(() => { + set(this.widgetsMap, [widgetKey, "sort_order"], resultSequence); + }); + } catch (error) { + console.error("Failed to move widget"); + throw error; + } + }; +} diff --git a/web/core/store/workspace/index.ts b/web/core/store/workspace/index.ts index 950fc8ddbc5..112d4395518 100644 --- a/web/core/store/workspace/index.ts +++ b/web/core/store/workspace/index.ts @@ -8,6 +8,7 @@ import { WorkspaceService } from "@/plane-web/services"; import { CoreRootStore } from "@/store/root.store"; // sub-stores import { ApiTokenStore, IApiTokenStore } from "./api-token.store"; +import { HomeStore, IHomeStore } from "./home"; import { IWebhookStore, WebhookStore } from "./webhook.store"; export interface IWorkspaceRootStore { @@ -30,6 +31,7 @@ export interface IWorkspaceRootStore { // sub-stores webhook: IWebhookStore; apiToken: IApiTokenStore; + home: IHomeStore; } export class WorkspaceRootStore implements IWorkspaceRootStore { @@ -41,6 +43,7 @@ export class WorkspaceRootStore implements IWorkspaceRootStore { // root store router; user; + home; // sub-stores webhook: IWebhookStore; apiToken: IApiTokenStore; @@ -69,6 +72,7 @@ export class WorkspaceRootStore implements IWorkspaceRootStore { // root store this.router = _rootStore.router; this.user = _rootStore.user; + this.home = new HomeStore(); // sub-stores this.webhook = new WebhookStore(_rootStore); this.apiToken = new ApiTokenStore(_rootStore); diff --git a/web/core/store/workspace/link.store.ts b/web/core/store/workspace/link.store.ts new file mode 100644 index 00000000000..a6b2cd40ecd --- /dev/null +++ b/web/core/store/workspace/link.store.ts @@ -0,0 +1,128 @@ +import set from "lodash/set"; +import { action, makeObservable, observable, runInAction } from "mobx"; +// types +import { TLink, TLinkIdMap, TLinkMap } from "@plane/types"; +// services +import { WorkspaceService } from "@/plane-web/services"; + +export interface IWorkspaceLinkStoreActions { + addLinks: (projectId: string, links: TLink[]) => void; + fetchLinks: (workspaceSlug: string) => Promise; + createLink: (workspaceSlug: string, data: Partial) => Promise; + updateLink: (workspaceSlug: string, linkId: string, data: Partial) => Promise; + removeLink: (workspaceSlug: string, linkId: string) => Promise; + setLinkData: (link: TLink | undefined) => void; + toggleLinkModal: (isOpen: boolean) => void; +} + +export interface IWorkspaceLinkStore extends IWorkspaceLinkStoreActions { + // observables + links: TLinkIdMap; + linkMap: TLinkMap; + linkData: TLink | undefined; + isLinkModalOpen: boolean; + // helper methods + getLinksByWorkspaceId: (projectId: string) => string[] | undefined; + getLinkById: (linkId: string) => TLink | undefined; +} + +export class WorkspaceLinkStore implements IWorkspaceLinkStore { + // observables + links: TLinkIdMap = {}; + linkMap: TLinkMap = {}; + linkData: TLink | undefined = undefined; + isLinkModalOpen = false; + // services + workspaceService: WorkspaceService; + + constructor() { + makeObservable(this, { + // observables + links: observable, + linkMap: observable, + linkData: observable, + isLinkModalOpen: observable, + // actions + addLinks: action.bound, + fetchLinks: action, + createLink: action, + updateLink: action, + removeLink: action, + setLinkData: action, + toggleLinkModal: action, + }); + // services + this.workspaceService = new WorkspaceService(); + } + + // helper methods + getLinksByWorkspaceId = (projectId: string) => { + if (!projectId) return undefined; + return this.links[projectId] ?? undefined; + }; + + getLinkById = (linkId: string) => { + if (!linkId) return undefined; + return this.linkMap[linkId] ?? undefined; + }; + + // actions + setLinkData = (link: TLink | undefined) => { + runInAction(() => { + this.linkData = link; + }); + }; + + toggleLinkModal = (isOpen: boolean) => { + runInAction(() => { + this.isLinkModalOpen = isOpen; + }); + }; + + addLinks = (workspaceSlug: string, links: TLink[]) => { + runInAction(() => { + this.links[workspaceSlug] = links.map((link) => link.id); + links.forEach((link) => set(this.linkMap, link.id, link)); + }); + }; + + fetchLinks = async (workspaceSlug: string) => { + const response = await this.workspaceService.fetchWorkspaceLinks(workspaceSlug); + this.addLinks(workspaceSlug, response); + return response; + }; + + createLink = async (workspaceSlug: string, data: Partial) => { + console.log("hereee"); + const response = await this.workspaceService.createWorkspaceLink(workspaceSlug, data); + + runInAction(() => { + this.links[workspaceSlug] = [response.id, ...(this.links[workspaceSlug] ?? [])]; + set(this.linkMap, response.id, response); + }); + return response; + }; + + updateLink = async (workspaceSlug: string, linkId: string, data: Partial) => { + runInAction(() => { + Object.keys(data).forEach((key) => { + set(this.linkMap, [linkId, key], data[key as keyof TLink]); + }); + }); + + const response = await this.workspaceService.updateWorkspaceLink(workspaceSlug, linkId, data); + return response; + }; + + removeLink = async (workspaceSlug: string, linkId: string) => { + // const issueLinkCount = this.getLinksByWorkspaceId(projectId)?.length ?? 0; + await this.workspaceService.deleteWorkspaceLink(workspaceSlug, linkId); + + const linkIndex = this.links[workspaceSlug].findIndex((link) => link === linkId); + if (linkIndex >= 0) + runInAction(() => { + this.links[workspaceSlug].splice(linkIndex, 1); + delete this.linkMap[linkId]; + }); + }; +} diff --git a/web/ee/components/stickies/index.ts b/web/ee/components/stickies/index.ts new file mode 100644 index 00000000000..81560ba6812 --- /dev/null +++ b/web/ee/components/stickies/index.ts @@ -0,0 +1 @@ +export * from "ce/components/stickies";