mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
[WEB-2846] feat: home integrations (#6321)
* wip * chore: wip * fix: preserved old component * fix * fix: seperate route added * fix * Only return user ID of project members * Return issue ID * fix: recents api integrations * fix: types * fix: types * fix: added tooltips * chore: added apis --------- Co-authored-by: sangeethailango <sangeethailango21@gmail.com> Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
This commit is contained in:
@@ -150,16 +150,7 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Issue
|
||||
fields = [
|
||||
"name",
|
||||
"state",
|
||||
"priority",
|
||||
"assignees",
|
||||
"type",
|
||||
"sequence_id",
|
||||
"project_id",
|
||||
"project_identifier",
|
||||
]
|
||||
fields = ["id", "name", "state", "priority", "assignees", "type", "sequence_id", "project_id", "project_identifier", ]
|
||||
|
||||
def get_project_identifier(self, obj):
|
||||
project = obj.project
|
||||
@@ -167,14 +158,6 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
return project.identifier if project else None
|
||||
|
||||
|
||||
class ProjectMemberSerializer(BaseSerializer):
|
||||
member = UserLiteSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
fields = ["member"]
|
||||
|
||||
|
||||
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_members = serializers.SerializerMethodField()
|
||||
|
||||
@@ -183,14 +166,9 @@ class ProjectRecentVisitSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "name", "logo_props", "project_members", "identifier"]
|
||||
|
||||
def get_project_members(self, obj):
|
||||
members = ProjectMember.objects.filter(project_id=obj.id).select_related(
|
||||
"member"
|
||||
)
|
||||
|
||||
serializer = ProjectMemberSerializer(members, many=True)
|
||||
return serializer.data
|
||||
|
||||
|
||||
members = ProjectMember.objects.filter(project_id=obj.id).values_list("member", flat=True)
|
||||
return members
|
||||
|
||||
class PageRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_id = serializers.SerializerMethodField()
|
||||
project_identifier = serializers.SerializerMethodField()
|
||||
|
||||
76
packages/types/src/home.d.ts
vendored
Normal file
76
packages/types/src/home.d.ts
vendored
Normal file
@@ -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" | "recent_activity" | "stickies";
|
||||
|
||||
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: string;
|
||||
name: string;
|
||||
is_enabled: boolean;
|
||||
sort_order: number;
|
||||
};
|
||||
1
packages/types/src/index.d.ts
vendored
1
packages/types/src/index.d.ts
vendored
@@ -38,3 +38,4 @@ export * from "./timezone";
|
||||
export * from "./activity";
|
||||
export * from "./epics";
|
||||
export * from "./charts";
|
||||
export * from "./home";
|
||||
|
||||
60
web/app/[workspaceSlug]/(projects)/home/header.tsx
Normal file
60
web/app/[workspaceSlug]/(projects)/home/header.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink label="Home" icon={<Home className="h-4 w-4 text-custom-text-300" />} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
<a
|
||||
onClick={() =>
|
||||
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"
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? githubWhiteImage : githubBlackImage}
|
||||
height={16}
|
||||
width={16}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
<span className="hidden text-xs font-medium sm:hidden md:block">Star us on GitHub</span>
|
||||
</a>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
28
web/app/[workspaceSlug]/(projects)/home/page.tsx
Normal file
28
web/app/[workspaceSlug]/(projects)/home/page.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<AppHeader header={<WorkspaceDashboardHeader />} />
|
||||
<ContentWrapper>
|
||||
<PageHead title={pageTitle} />
|
||||
<WorkspaceHomeView />
|
||||
</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceDashboardPage;
|
||||
1
web/ce/components/home/header.tsx
Normal file
1
web/ce/components/home/header.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export const HomePageHeader = () => <></>;
|
||||
1
web/ce/components/stickies/index.ts
Normal file
1
web/ce/components/stickies/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./widget";
|
||||
1
web/ce/components/stickies/widget.tsx
Normal file
1
web/ce/components/stickies/widget.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export const StickiesWidget = () => <></>;
|
||||
@@ -18,6 +18,7 @@ interface IListItemProps {
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
disableLink?: boolean;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
actionItemContainerClassName?: string;
|
||||
isSidebarOpen?: boolean;
|
||||
quickActionElement?: JSX.Element;
|
||||
@@ -38,6 +39,7 @@ export const ListItem: FC<IListItemProps> = (props) => {
|
||||
actionItemContainerClassName = "",
|
||||
isSidebarOpen = false,
|
||||
quickActionElement,
|
||||
itemClassName = "",
|
||||
} = props;
|
||||
|
||||
// router
|
||||
@@ -61,7 +63,7 @@ export const ListItem: FC<IListItemProps> = (props) => {
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
|
||||
<div className={cn("relative flex w-full items-center justify-between gap-3 overflow-hidden", itemClassName)}>
|
||||
<ControlLink
|
||||
className="relative flex w-full items-center gap-3 overflow-hidden"
|
||||
href={itemLink}
|
||||
|
||||
51
web/core/components/home/home-dashboard-widgets.tsx
Normal file
51
web/core/components/home/home-dashboard-widgets.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
// components
|
||||
import { HomePageHeader } from "@/plane-web/components/home/header";
|
||||
import { StickiesWidget } from "@/plane-web/components/stickies";
|
||||
import { RecentActivityWidget } from "./widgets";
|
||||
import { DashboardQuickLinks } from "./widgets/links";
|
||||
import { ManageWidgetsModal } from "./widgets/manage";
|
||||
|
||||
const WIDGETS_LIST: {
|
||||
[key in THomeWidgetKeys]: { component: React.FC<THomeWidgetProps>; fullWidth: boolean };
|
||||
} = {
|
||||
quick_links: { component: DashboardQuickLinks, fullWidth: false },
|
||||
recent_activity: { component: RecentActivityWidget, fullWidth: false },
|
||||
stickies: { component: StickiesWidget, fullWidth: false },
|
||||
};
|
||||
|
||||
export const DashboardWidgets = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { toggleWidgetSettings, showWidgetSettings } = useHome();
|
||||
|
||||
if (!workspaceSlug) return null;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-7">
|
||||
<HomePageHeader />
|
||||
<ManageWidgetsModal
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
isModalOpen={showWidgetSettings}
|
||||
handleOnClose={() => toggleWidgetSettings(false)}
|
||||
/>
|
||||
|
||||
{Object.entries(WIDGETS_LIST).map(([key, widget]) => {
|
||||
const WidgetComponent = widget.component;
|
||||
if (widget.fullWidth)
|
||||
return (
|
||||
<div key={key} className="lg:col-span-2">
|
||||
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
|
||||
</div>
|
||||
);
|
||||
else return <WidgetComponent key={key} workspaceSlug={workspaceSlug.toString()} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
4
web/core/components/home/index.ts
Normal file
4
web/core/components/home/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./widgets";
|
||||
export * from "./home-dashboard-widgets";
|
||||
export * from "./project-empty-state";
|
||||
export * from "./root";
|
||||
46
web/core/components/home/project-empty-state.tsx
Normal file
46
web/core/components/home/project-empty-state.tsx
Normal file
@@ -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 (
|
||||
<div className="mx-auto flex h-full flex-col justify-center space-y-4 lg:w-3/5">
|
||||
<h4 className="text-xl font-semibold">Overview of your projects, activity, and metrics</h4>
|
||||
<p className="text-custom-text-300">
|
||||
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.
|
||||
</p>
|
||||
<Image src={ProjectEmptyStateImage} className="w-full" alt="Project empty state" />
|
||||
{canCreateProject && (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setTrackElement("Project empty state");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
>
|
||||
Build your first project
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
96
web/core/components/home/root.tsx
Normal file
96
web/core/components/home/root.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
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, useDashboard, 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 { homeDashboardId, fetchHomeDashboardWidgets } = useDashboard();
|
||||
const { toggleWidgetSettings } = useHome();
|
||||
const { joinedProjectIds, loader } = useProject();
|
||||
|
||||
const [windowWidth] = useSize();
|
||||
|
||||
const handleTourCompleted = () => {
|
||||
updateTourCompleted()
|
||||
.then(() => {
|
||||
captureEvent(PRODUCT_TOUR_COMPLETED, {
|
||||
user_id: currentUser?.id,
|
||||
state: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
// fetch home dashboard widgets on workspace change
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
fetchHomeDashboardWidgets(workspaceSlug?.toString());
|
||||
}, [fetchHomeDashboardWidgets, workspaceSlug]);
|
||||
|
||||
// TODO: refactor loader implementation
|
||||
return (
|
||||
<>
|
||||
{currentUserProfile && !currentUserProfile.is_tour_completed && (
|
||||
<div className="fixed left-0 top-0 z-20 grid h-full w-full place-items-center bg-custom-backdrop bg-opacity-50 transition-opacity">
|
||||
<TourRoot onComplete={handleTourCompleted} />
|
||||
</div>
|
||||
)}
|
||||
{homeDashboardId && joinedProjectIds && (
|
||||
<>
|
||||
{joinedProjectIds.length > 0 || loader ? (
|
||||
<>
|
||||
<IssuePeekOverview />
|
||||
<ContentWrapper
|
||||
className={cn("gap-7 bg-custom-background-90/20", {
|
||||
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
|
||||
})}
|
||||
>
|
||||
{currentUser && (
|
||||
<UserGreetingsView user={currentUser} handleWidgetModal={() => toggleWidgetSettings(true)} />
|
||||
)}
|
||||
|
||||
<DashboardWidgets />
|
||||
</ContentWrapper>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
type={EmptyStateType.WORKSPACE_DASHBOARD}
|
||||
primaryButtonOnClick={() => {
|
||||
setTrackElement("Dashboard empty state");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
63
web/core/components/home/user-greetings.tsx
Normal file
63
web/core/components/home/user-greetings.tsx
Normal file
@@ -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<IUserGreetingsView> = (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 (
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-center">
|
||||
Good {greeting}, {user?.first_name} {user?.last_name}
|
||||
</h3>
|
||||
<h6 className="flex items-center gap-2 font-medium text-custom-text-400">
|
||||
<div>{greeting === "morning" ? "🌤️" : greeting === "afternoon" ? "🌥️" : "🌙️"}</div>
|
||||
<div>
|
||||
{weekDay}, {date} {timeString}
|
||||
</div>
|
||||
</h6>
|
||||
</div>
|
||||
{/* <button
|
||||
onClick={handleWidgetModal}
|
||||
className="flex items-center gap-2 font-medium text-custom-text-300 justify-center border border-custom-border-200 rounded p-2 my-auto mb-0"
|
||||
>
|
||||
<Shapes size={16} />
|
||||
<div className="text-xs font-medium">Manage widgets</div>
|
||||
</button> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
web/core/components/home/widgets/empty-states/index.ts
Normal file
1
web/core/components/home/widgets/empty-states/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
118
web/core/components/home/widgets/empty-states/root.tsx
Normal file
118
web/core/components/home/widgets/empty-states/root.tsx
Normal file
@@ -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: <Briefcase className="w-[40px] h-[40px] text-custom-primary-100" />,
|
||||
cta: {
|
||||
text: "Create Project",
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, 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: <Users className="w-[40px] h-[40px] text-custom-primary-100" />,
|
||||
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: <Hotel className="w-[40px] h-[40px] text-custom-primary-100" />,
|
||||
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() !== "" ? (
|
||||
<Link href={`/${workspaceSlug}/profile/${currentUser?.id}`}>
|
||||
<span className="relative flex h-6 w-6 items-center justify-center rounded-full p-4 capitalize text-white">
|
||||
<img
|
||||
src={getFileURL(currentUser?.avatar_url)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
|
||||
alt={currentUser?.display_name || currentUser?.email}
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={`/${workspaceSlug}/profile/${currentUser?.id}`}>
|
||||
<span className="relative flex h-6 w-6 items-center justify-center rounded-full bg-gray-700 p-4 capitalize text-white text-sm">
|
||||
{(currentUser?.email ?? currentUser?.display_name ?? "?")[0]}
|
||||
</span>
|
||||
</Link>
|
||||
),
|
||||
cta: {
|
||||
text: "Personalize account",
|
||||
link: "/profile",
|
||||
},
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{EMPTY_STATE_DATA.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col items-center justify-center py-8 bg-custom-background-100 rounded-lg text-center border border-custom-border-200/40"
|
||||
>
|
||||
<div className="flex items-center justify-center bg-custom-primary-100/10 rounded-full w-[80px] h-[80px] mb-4">
|
||||
<span className="text-3xl my-auto">{item.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-custom-text-100 mb-2">{item.title}</h3>
|
||||
<p className="text-sm text-custom-text-200 mb-4 w-[80%] flex-1">{item.description}</p>
|
||||
|
||||
{item.cta.link ? (
|
||||
<Link
|
||||
href={item.cta.link}
|
||||
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
|
||||
>
|
||||
{item.cta.text}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
|
||||
onClick={item.cta.onClick}
|
||||
>
|
||||
{item.cta.text}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
4
web/core/components/home/widgets/index.ts
Normal file
4
web/core/components/home/widgets/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./empty-states";
|
||||
export * from "./loaders";
|
||||
export * from "./recents";
|
||||
export * from "./empty-states";
|
||||
20
web/core/components/home/widgets/links/action.tsx
Normal file
20
web/core/components/home/widgets/links/action.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
type TProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
export const AddLink = (props: TProps) => {
|
||||
const { onClick } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn btn-primary flex bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="rounded p-2 bg-custom-background-80/40 w-8 h-8 my-auto">
|
||||
<PlusIcon className="h-4 w-4 stroke-2 text-custom-text-350" />
|
||||
</div>
|
||||
<div className="text-sm font-medium my-auto">Add quick Link</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -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<TLinkOperations, "remove">;
|
||||
|
||||
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<TLinkCreateEditModal> = observer((props) => {
|
||||
// props
|
||||
const { setLinkData, isModalOpen, handleOnClose, linkOperations, preloadedData } = props;
|
||||
// react hook form
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
} = useForm<TLinkCreateFormFieldOptions>({
|
||||
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 (
|
||||
<ModalCore isOpen={isModalOpen} handleClose={onClose}>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="space-y-5 p-5">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">
|
||||
{preloadedData?.id ? "Update" : "Add"} quick link
|
||||
</h3>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="url" className="mb-2 text-custom-text-200 text-base font-medium">
|
||||
URL
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: "URL is required",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="url"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.url)}
|
||||
placeholder="Type or paste a URL"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.url && <span className="text-xs text-red-500">URL is invalid</span>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-2 text-custom-text-200 text-base font-medium">
|
||||
Display title
|
||||
<span className="text-[10px] block">Optional</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="title"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.title)}
|
||||
placeholder="What you'd like to see this link as"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{preloadedData?.id ? (isSubmitting ? "Updating" : "Update") : isSubmitting ? "Adding" : "Add"} quick link
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
3
web/core/components/home/widgets/links/index.ts
Normal file
3
web/core/components/home/widgets/links/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./root";
|
||||
export * from "./links";
|
||||
export * from "./link-detail";
|
||||
126
web/core/components/home/widgets/links/link-detail.tsx
Normal file
126
web/core/components/home/widgets/links/link-detail.tsx
Normal file
@@ -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<TProjectLinkDetail> = 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 (
|
||||
<div className="group btn btn-primary flex bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4 hover:shadow-md">
|
||||
<div className="rounded p-2 bg-custom-background-80/40 w-8 h-8 my-auto">
|
||||
<Link2 className="h-4 w-4 stroke-2 text-custom-text-350 -rotate-45" />
|
||||
</div>
|
||||
<div className="my-auto flex-1">
|
||||
<div className="text-sm font-medium truncate">{linkDetail.title || linkDetail.url}</div>
|
||||
<div className="text-xs font-medium text-custom-text-400">{calculateTimeAgo(linkDetail.created_at)}</div>
|
||||
</div>
|
||||
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<EllipsisVertical className="opacity-0 h-4 w-4 stroke-2 text-custom-text-350 group-hover:opacity-100" />
|
||||
}
|
||||
placement="bottom-end"
|
||||
menuItemsClassName="z-20"
|
||||
closeOnSelect
|
||||
className=" my-auto"
|
||||
>
|
||||
{MENU_ITEMS.map((item) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
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.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
77
web/core/components/home/widgets/links/links.tsx
Normal file
77
web/core/components/home/widgets/links/links.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// computed
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { AddLink } from "./action";
|
||||
import { ProjectLinkDetail } from "./link-detail";
|
||||
import { TLinkOperations } from "./use-links";
|
||||
import { EWidgetKeys, WidgetLoader } from "../loaders";
|
||||
|
||||
export type TLinkOperationsModal = Exclude<TLinkOperations, "create">;
|
||||
|
||||
export type TProjectLinkList = {
|
||||
linkOperations: TLinkOperationsModal;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const ProjectLinkList: FC<TProjectLinkList> = 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 <WidgetLoader widgetKey={EWidgetKeys.QUICK_LINKS} />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2 mb-2 flex-wrap justify-center ">
|
||||
{links &&
|
||||
links.length > 0 &&
|
||||
(showAll ? links : links.slice(0, 2 * columnCount - 1)).map((linkId) => (
|
||||
<ProjectLinkDetail key={linkId} linkId={linkId} linkOperations={linkOperations} />
|
||||
))}
|
||||
|
||||
{/* Add new link */}
|
||||
<AddLink onClick={() => toggleLinkModal(true)} />
|
||||
</div>
|
||||
{links.length > 2 * columnCount - 1 && (
|
||||
<button
|
||||
className="flex items-center justify-center gap-1 rounded-md px-2 py-1 text-sm font-medium text-custom-primary-100 mx-auto"
|
||||
onClick={() => setShowAll((state) => !state)}
|
||||
>
|
||||
{showAll ? "Show less" : "Show more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
40
web/core/components/home/widgets/links/root.tsx
Normal file
40
web/core/components/home/widgets/links/root.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { LinkCreateUpdateModal } from "./create-update-link-modal";
|
||||
import { ProjectLinkList } from "./links";
|
||||
import { useLinks } from "./use-links";
|
||||
import { THomeWidgetProps } from "@plane/types";
|
||||
|
||||
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 (
|
||||
<>
|
||||
<LinkCreateUpdateModal
|
||||
isModalOpen={isLinkModalOpen}
|
||||
handleOnClose={() => toggleLinkModal(false)}
|
||||
linkOperations={linkOperations}
|
||||
preloadedData={linkData}
|
||||
setLinkData={setLinkData}
|
||||
/>
|
||||
<div className="flex mx-auto flex-wrap border-b border-custom-border-100 pb-4 w-full justify-center">
|
||||
{/* rendering links */}
|
||||
<ProjectLinkList workspaceSlug={workspaceSlug} linkOperations={linkOperations} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
98
web/core/components/home/widgets/links/use-links.tsx
Normal file
98
web/core/components/home/widgets/links/use-links.tsx
Normal file
@@ -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<TProjectLink>) => Promise<void>;
|
||||
update: (linkId: string, data: Partial<TProjectLink>) => Promise<void>;
|
||||
remove: (linkId: string) => Promise<void>;
|
||||
};
|
||||
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<TProjectLink>) => {
|
||||
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<TProjectLink>) => {
|
||||
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 };
|
||||
};
|
||||
1
web/core/components/home/widgets/loaders/index.ts
Normal file
1
web/core/components/home/widgets/loaders/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./loader";
|
||||
25
web/core/components/home/widgets/loaders/loader.tsx
Normal file
25
web/core/components/home/widgets/loaders/loader.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// components
|
||||
import { RecentActivityWidgetLoader } from "./recent-activity";
|
||||
import { QuickLinksWidgetLoader } from "./quick-links";
|
||||
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
widgetKey: EWidgetKeys;
|
||||
};
|
||||
|
||||
export enum EWidgetKeys {
|
||||
RECENT_ACTIVITY = "recent_activity",
|
||||
QUICK_LINKS = "quick_links",
|
||||
}
|
||||
|
||||
export const WidgetLoader: React.FC<Props> = (props) => {
|
||||
const { widgetKey } = props;
|
||||
|
||||
const loaders = {
|
||||
[EWidgetKeys.RECENT_ACTIVITY]: <RecentActivityWidgetLoader />,
|
||||
[EWidgetKeys.QUICK_LINKS]: <QuickLinksWidgetLoader />,
|
||||
};
|
||||
|
||||
return loaders[widgetKey];
|
||||
};
|
||||
13
web/core/components/home/widgets/loaders/quick-links.tsx
Normal file
13
web/core/components/home/widgets/loaders/quick-links.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import range from "lodash/range";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const QuickLinksWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl gap-2 flex flex-wrap justify-center">
|
||||
{range(4).map((index) => (
|
||||
<Loader.Item key={index} height="56px" width="230px" />
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
20
web/core/components/home/widgets/loaders/recent-activity.tsx
Normal file
20
web/core/components/home/widgets/loaders/recent-activity.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import range from "lodash/range";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const RecentActivityWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl px-2 space-y-6">
|
||||
{range(7).map((index) => (
|
||||
<div key={index} className="flex items-start gap-3.5">
|
||||
<div className="flex-shrink-0">
|
||||
<Loader.Item height="32px" width="32px" />
|
||||
</div>
|
||||
<div className="space-y-3 flex-shrink-0 w-full my-auto">
|
||||
<Loader.Item height="15px" width="70%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
36
web/core/components/home/widgets/manage/index.tsx
Normal file
36
web/core/components/home/widgets/manage/index.tsx
Normal file
@@ -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<TProps> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, isModalOpen, handleOnClose } = props;
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isModalOpen} handleClose={handleOnClose} width={EModalWidth.MD}>
|
||||
<div className="p-4">
|
||||
<div className="font-medium text-xl">Manage widgets</div>
|
||||
<WidgetList workspaceSlug={workspaceSlug} />
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="md" onClick={handleOnClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit">
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -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<Props> = observer((props) => {
|
||||
const { isDragging } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-center rounded text-custom-sidebar-text-400 cursor-grab mr-2", {
|
||||
"cursor-grabbing": isDragging,
|
||||
})}
|
||||
>
|
||||
<DragHandle className="bg-transparent" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
130
web/core/components/home/widgets/manage/widget-item.tsx
Normal file
130
web/core/components/home/widgets/manage/widget-item.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"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 { createRoot } from "react-dom/client";
|
||||
// ui
|
||||
import { InstructionType } from "@plane/types";
|
||||
// components
|
||||
import { DropIndicator, ToggleSwitch } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
import { WidgetItemDragHandle } from "./widget-item-drag-handle";
|
||||
import { getCanDrop, getInstructionFromPayload } from "./widget.helpers";
|
||||
|
||||
type Props = {
|
||||
isLastChild: boolean;
|
||||
widget: any;
|
||||
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
|
||||
};
|
||||
|
||||
export const WidgetItem: FC<Props> = observer((props) => {
|
||||
// props
|
||||
const { isLastChild, widget, handleDrop } = props;
|
||||
//state
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
|
||||
|
||||
//ref
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// drag and drop
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
|
||||
if (!element) return;
|
||||
const initialData = { id: widget.id, 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(<div className="rounded bg-custom-background-100 text-sm p-1 pr-2">{widget.title}</div>);
|
||||
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.id]);
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<DropIndicator isVisible={instruction === "reorder-above"} />
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={cn(
|
||||
"px-2 relative flex items-center py-2 font-medium text-sm capitalize group/widget-item rounded hover:bg-custom-background-80 justify-between",
|
||||
{
|
||||
"cursor-grabbing bg-custom-background-80": isDragging,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<WidgetItemDragHandle sort_order={widget.sort_order} isDragging={isDragging} />
|
||||
<div>{widget.title}</div>
|
||||
</div>
|
||||
{/* <ToggleSwitch /> */}
|
||||
</div>
|
||||
{isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
49
web/core/components/home/widgets/manage/widget-list.tsx
Normal file
49
web/core/components/home/widgets/manage/widget-list.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
DragLocationHistory,
|
||||
DropTargetRecord,
|
||||
ElementDragPayload,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
|
||||
import { WidgetItem } from "./widget-item";
|
||||
import { getInstructionFromPayload, TargetData } from "./widget.helpers";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
|
||||
const WIDGETS_LIST = [
|
||||
{ id: 1, title: "quick links" },
|
||||
{ id: 2, title: "recents" },
|
||||
{ id: 3, title: "stickies" },
|
||||
];
|
||||
export const WidgetList = ({ workspaceSlug }: { workspaceSlug: string }) => {
|
||||
const { reorderWidgets } = 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) {
|
||||
reorderWidgets(workspaceSlug, sourceData.id, droppedId, instruction); /** sequence */
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-4">
|
||||
{WIDGETS_LIST.map((widget, index) => (
|
||||
<WidgetItem
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
isLastChild={index === WIDGETS_LIST.length - 1}
|
||||
handleDrop={handleDrop}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
62
web/core/components/home/widgets/manage/widget.helpers.ts
Normal file
62
web/core/components/home/widgets/manage/widget.helpers.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import { IFavorite, InstructionType, IPragmaticPayloadLocation, TDropTarget } 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 favorite 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 favorite,
|
||||
// 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 favorite can be dropped onto the droptarget
|
||||
* @param source
|
||||
* @param favorite
|
||||
* @returns
|
||||
*/
|
||||
export const getCanDrop = (source: TDropTarget, favorite: IFavorite | undefined) => {
|
||||
const sourceData = source?.data;
|
||||
|
||||
if (!sourceData) return false;
|
||||
|
||||
// a favorite cannot be dropped on to itself
|
||||
if (sourceData.id === favorite?.id) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
51
web/core/components/home/widgets/recents/filters.tsx
Normal file
51
web/core/components/home/widgets/recents/filters.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { TRecentActivityFilterKeys } from "@plane/types";
|
||||
|
||||
export type TFiltersDropdown = {
|
||||
className?: string;
|
||||
activeFilter: TRecentActivityFilterKeys;
|
||||
setActiveFilter: (filter: TRecentActivityFilterKeys) => void;
|
||||
filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode }[];
|
||||
};
|
||||
|
||||
export const FiltersDropdown: FC<TFiltersDropdown> = observer((props) => {
|
||||
const { className, activeFilter, setActiveFilter, filters } = props;
|
||||
|
||||
const DropdownOptions = () =>
|
||||
filters?.map((filter) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={filter.name}
|
||||
className="flex items-center gap-2 truncate text-custom-text-200"
|
||||
onClick={() => {
|
||||
setActiveFilter(filter.name);
|
||||
}}
|
||||
>
|
||||
{filter.icon && <div>{filter.icon}</div>}
|
||||
<div className="truncate font-medium text-xs capitalize">{`${filter.name}s`}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
));
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className={cn("flex justify-center text-xs text-custom-text-200 w-fit ", className)}
|
||||
placement="bottom-start"
|
||||
customButton={
|
||||
<button className="flex hover:bg-custom-background-80 px-2 py-1 rounded gap-1 capitalize border border-custom-border-200">
|
||||
<span className="font-medium text-sm my-auto"> {activeFilter && `${activeFilter}s`}</span>
|
||||
<ChevronDown className={cn("size-3 my-auto text-custom-text-300 hover:text-custom-text-200 duration-300")} />
|
||||
</button>
|
||||
}
|
||||
customButtonClassName="flex justify-center"
|
||||
closeOnSelect
|
||||
>
|
||||
<DropdownOptions />
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
79
web/core/components/home/widgets/recents/index.tsx
Normal file
79
web/core/components/home/widgets/recents/index.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
|
||||
// components
|
||||
import { FiltersDropdown } from "./filters";
|
||||
import { RecentIssue } from "./issue";
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
import useSWR from "swr";
|
||||
import { RecentProject } from "./project";
|
||||
import { RecentPage } from "./page";
|
||||
import { EWidgetKeys, WidgetLoader } from "../loaders";
|
||||
import { Briefcase, FileText } from "lucide-react";
|
||||
import { LayersIcon } from "@plane/ui";
|
||||
import { EmptyWorkspace } from "../empty-states";
|
||||
|
||||
const WIDGET_KEY = EWidgetKeys.RECENT_ACTIVITY;
|
||||
const workspaceService = new WorkspaceService();
|
||||
const filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode }[] = [
|
||||
{ name: "all item" },
|
||||
{ name: "issue", icon: <LayersIcon className="w-4 h-4" /> },
|
||||
{ name: "page", icon: <FileText size={16} /> },
|
||||
{ name: "project", icon: <Briefcase size={16} /> },
|
||||
];
|
||||
|
||||
export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props) => {
|
||||
const { workspaceSlug } = props;
|
||||
// state
|
||||
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(filters[0].name);
|
||||
// ref
|
||||
const ref = useRef<HTMLDivElement>(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 <RecentPage activity={activity} ref={ref} workspaceSlug={workspaceSlug} />;
|
||||
case "project":
|
||||
return <RecentProject activity={activity} ref={ref} workspaceSlug={workspaceSlug} />;
|
||||
case "issue":
|
||||
return <RecentIssue activity={activity} ref={ref} workspaceSlug={workspaceSlug} />;
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoading && recents?.length === 0) return <EmptyWorkspace />;
|
||||
|
||||
return (
|
||||
<div ref={ref} className=" max-h-[500px] overflow-y-scroll">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-base font-semibold text-custom-text-350 hover:underline">Recents</div>
|
||||
|
||||
<FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />
|
||||
</div>
|
||||
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
|
||||
{!isLoading &&
|
||||
recents?.length > 0 &&
|
||||
recents.map((activity: TActivityEntityData) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
83
web/core/components/home/widgets/recents/issue.tsx
Normal file
83
web/core/components/home/widgets/recents/issue.tsx
Normal file
@@ -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<HTMLDivElement>;
|
||||
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 (
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
itemLink=""
|
||||
title={""}
|
||||
prependTitleElement={
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
|
||||
<IssueIdentifier
|
||||
issueTypeId={issueDetails?.type}
|
||||
projectId={issueDetails?.project_id || ""}
|
||||
projectIdentifier={issueDetails?.project_identifier || ""}
|
||||
issueSequenceId={issueDetails?.sequence_id || ""}
|
||||
textContainerClassName="text-custom-sidebar-text-400 text-sm whitespace-nowrap"
|
||||
/>
|
||||
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{issueDetails?.name}</div>
|
||||
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
|
||||
</div>
|
||||
}
|
||||
quickActionElement={
|
||||
<div className="flex gap-4">
|
||||
<Tooltip tooltipHeading="State" tooltipContent={state?.name ?? "State"}>
|
||||
<div>
|
||||
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} className="h-4 w-4 my-auto" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={issueDetails?.priority ?? "Priority"}>
|
||||
<div>
|
||||
<PriorityIcon priority={issueDetails?.priority} withContainer size={12} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
{issueDetails?.assignees?.length > 0 && (
|
||||
<div className="h-5">
|
||||
<MemberDropdown
|
||||
projectId={issueDetails?.project_id}
|
||||
value={issueDetails?.assignees}
|
||||
onChange={() => {}}
|
||||
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=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
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 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
63
web/core/components/home/widgets/recents/page.tsx
Normal file
63
web/core/components/home/widgets/recents/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { TActivityEntityData, TPageEntityData } from "@plane/types";
|
||||
import { Avatar, Logo } from "@plane/ui";
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
import { FileText } from "lucide-react";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
import { useMember } from "@/hooks/store";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type BlockProps = {
|
||||
activity: TActivityEntityData;
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
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 (
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
itemLink=""
|
||||
title={""}
|
||||
prependTitleElement={
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-8 h-8">
|
||||
<>
|
||||
{pageDetails?.logo_props?.in_use ? (
|
||||
<Logo logo={pageDetails?.logo_props} size={16} type="lucide" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-custom-text-300" />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
|
||||
{pageDetails?.project_identifier}
|
||||
</div>
|
||||
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{pageDetails?.name}</div>
|
||||
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
|
||||
</div>
|
||||
}
|
||||
quickActionElement={
|
||||
<div className="flex gap-4">
|
||||
<Avatar src={getFileURL(ownerDetails?.avatar_url ?? "")} name={ownerDetails?.display_name} />
|
||||
</div>
|
||||
}
|
||||
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}`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
71
web/core/components/home/widgets/recents/project.tsx
Normal file
71
web/core/components/home/widgets/recents/project.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { TActivityEntityData, TProjectEntityData } from "@plane/types";
|
||||
import { Logo } from "@plane/ui";
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
import { MemberDropdown } from "@/components/dropdowns";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type BlockProps = {
|
||||
activity: TActivityEntityData;
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
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 (
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
itemLink=""
|
||||
title={""}
|
||||
prependTitleElement={
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-8 h-8">
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
</div>
|
||||
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
|
||||
{projectDetails?.identifier}
|
||||
</div>
|
||||
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{projectDetails?.name}</div>
|
||||
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
|
||||
</div>
|
||||
}
|
||||
quickActionElement={
|
||||
<div className="flex gap-4">
|
||||
{projectDetails?.project_members?.length > 0 && (
|
||||
<div className="h-5">
|
||||
<MemberDropdown
|
||||
projectId={projectDetails?.id}
|
||||
value={projectDetails?.project_members}
|
||||
onChange={() => {}}
|
||||
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=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
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`);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -67,7 +67,7 @@ export const WorkspaceDashboardView = observer(() => {
|
||||
<>
|
||||
<IssuePeekOverview />
|
||||
<ContentWrapper
|
||||
className={cn("gap-7 bg-custom-background-90", {
|
||||
className={cn("gap-7 bg-custom-background-90/20", {
|
||||
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
|
||||
})}
|
||||
>
|
||||
|
||||
11
web/core/hooks/store/use-home.ts
Normal file
11
web/core/hooks/store/use-home.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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<IWorkspaceAuthWrapper> = 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<IWorkspaceAuthWrapper> = 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(
|
||||
|
||||
@@ -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<TLink[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/quick-links/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
|
||||
async createWorkspaceLink(workspaceSlug: string, data: Partial<TLink>): Promise<TLink> {
|
||||
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<TLink>): Promise<TLink> {
|
||||
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<void> {
|
||||
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<TSearchResponse> {
|
||||
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<TWidgetEntityData[]> {
|
||||
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<TWidgetEntityData>
|
||||
): Promise<TWidgetEntityData> {
|
||||
return this.patch(`/api/workspaces/${workspaceSlug}/home-preferences/${widgetKey}/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
114
web/core/store/workspace/home.ts
Normal file
114
web/core/store/workspace/home.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import orderBy from "lodash/orderBy";
|
||||
import set from "lodash/set";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { TWidgetEntityData } from "@plane/types";
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
import { IWorkspaceLinkStore, WorkspaceLinkStore } from "./link.store";
|
||||
|
||||
export interface IHomeStore {
|
||||
// observables
|
||||
showWidgetSettings: boolean;
|
||||
widgetsMap: Record<string, TWidgetEntityData>;
|
||||
//stores
|
||||
quickLinks: IWorkspaceLinkStore;
|
||||
// actions
|
||||
toggleWidgetSettings: (value?: boolean) => void;
|
||||
fetchWidgets: (workspaceSlug: string) => Promise<void>;
|
||||
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<string, TWidgetEntityData> = {};
|
||||
widgets: string[] = [];
|
||||
// stores
|
||||
quickLinks: IWorkspaceLinkStore;
|
||||
// services
|
||||
workspaceService: WorkspaceService;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
showWidgetSettings: observable,
|
||||
widgetsMap: observable,
|
||||
widgets: observable,
|
||||
// actions
|
||||
toggleWidgetSettings: action,
|
||||
fetchWidgets: action,
|
||||
reorderWidget: action,
|
||||
toggleWidget: action,
|
||||
});
|
||||
// services
|
||||
this.workspaceService = new WorkspaceService();
|
||||
|
||||
// stores
|
||||
this.quickLinks = new WorkspaceLinkStore();
|
||||
}
|
||||
|
||||
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 = widgets.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) => {
|
||||
try {
|
||||
let resultSequence = 10000;
|
||||
if (edge) {
|
||||
const sortedIds = orderBy(Object.values(this.widgetsMap), "sort_order", "desc").map((widget) => widget.key);
|
||||
const destinationSequence = this.widgetsMap[destinationId]?.sort_order || undefined;
|
||||
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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
128
web/core/store/workspace/link.store.ts
Normal file
128
web/core/store/workspace/link.store.ts
Normal file
@@ -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<TLink[]>;
|
||||
createLink: (workspaceSlug: string, data: Partial<TLink>) => Promise<TLink>;
|
||||
updateLink: (workspaceSlug: string, linkId: string, data: Partial<TLink>) => Promise<TLink>;
|
||||
removeLink: (workspaceSlug: string, linkId: string) => Promise<void>;
|
||||
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<TLink>) => {
|
||||
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<TLink>) => {
|
||||
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];
|
||||
});
|
||||
};
|
||||
}
|
||||
1
web/ee/components/stickies/index.ts
Normal file
1
web/ee/components/stickies/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "ce/components/stickies";
|
||||
Reference in New Issue
Block a user