Merge pull request #2125 from makeplane/sync/ce-ee

Sync: Community Changes
This commit is contained in:
pushya22
2025-01-06 20:42:45 +05:30
committed by GitHub
65 changed files with 2241 additions and 90 deletions

View File

@@ -6,6 +6,7 @@ from .base import BaseSerializer
from plane.db.models import Cycle, CycleIssue
from plane.utils.timezone_converter import convert_to_utc
class CycleSerializer(BaseSerializer):
total_issues = serializers.IntegerField(read_only=True)
cancelled_issues = serializers.IntegerField(read_only=True)
@@ -30,11 +31,18 @@ class CycleSerializer(BaseSerializer):
and data.get("end_date", None) is not None
):
project_id = self.initial_data.get("project_id") or self.instance.project_id
is_start_date_end_date_equal = (
True if data.get("start_date") == data.get("end_date") else False
)
data["start_date"] = convert_to_utc(
str(data.get("start_date").date()), project_id, is_start_date=True
date=str(data.get("start_date").date()),
project_id=project_id,
is_start_date=True,
)
data["end_date"] = convert_to_utc(
str(data.get("end_date", None).date()), project_id
date=str(data.get("end_date", None).date()),
project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
)
return data

View File

@@ -23,11 +23,18 @@ class CycleWriteSerializer(BaseSerializer):
and data.get("end_date", None) is not None
):
project_id = self.initial_data.get("project_id") or self.instance.project_id
is_start_date_end_date_equal = (
True if data.get("start_date") == data.get("end_date") else False
)
data["start_date"] = convert_to_utc(
str(data.get("start_date").date()), project_id, is_start_date=True
date=str(data.get("start_date").date()),
project_id=project_id,
is_start_date=True,
)
data["end_date"] = convert_to_utc(
str(data.get("end_date", None).date()), project_id
date=str(data.get("end_date", None).date()),
project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
)
return data

View File

@@ -172,16 +172,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
@@ -189,14 +180,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()
@@ -205,14 +188,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()

View File

@@ -150,10 +150,7 @@ class CycleViewSet(BaseViewSet):
& Q(end_date__gte=current_time_in_utc),
then=Value("CURRENT"),
),
When(
start_date__gt=current_time_in_utc,
then=Value("UPCOMING"),
),
When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")),
When(end_date__lt=current_time_in_utc, then=Value("COMPLETED")),
When(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
@@ -266,7 +263,9 @@ class CycleViewSet(BaseViewSet):
"created_by",
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
data = user_timezone_converter(
data, datetime_fields, request.user.user_timezone
)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
@@ -464,7 +463,9 @@ class CycleViewSet(BaseViewSet):
queryset = queryset.first()
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, request.user.user_timezone)
data = user_timezone_converter(
data, datetime_fields, request.user.user_timezone
)
recent_visited_task.delay(
slug=slug,
@@ -540,8 +541,17 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
start_date = convert_to_utc(str(start_date), project_id, is_start_date=True)
end_date = convert_to_utc(str(end_date), project_id)
is_start_date_end_date_equal = (
True if str("start_date") == str("end_date") else False
)
start_date = convert_to_utc(
date=str(start_date), project_id=project_id, is_start_date=True
)
end_date = convert_to_utc(
date=str(end_date),
project_id=project_id,
is_start_date_end_date_equal=is_start_date_end_date_equal,
)
# Check if any cycle intersects in the given interval
cycles = Cycle.objects.filter(

View File

@@ -3,6 +3,7 @@ from plane.db.models import Project
from datetime import datetime, time
from datetime import timedelta
def user_timezone_converter(queryset, datetime_fields, user_timezone):
# Create a timezone object for the user's timezone
user_tz = pytz.timezone(user_timezone)
@@ -28,7 +29,9 @@ def user_timezone_converter(queryset, datetime_fields, user_timezone):
return queryset_values
def convert_to_utc(date, project_id, is_start_date=False):
def convert_to_utc(
date, project_id, is_start_date=False, is_start_date_end_date_equal=False
):
"""
Converts a start date string to the project's local timezone at 12:00 AM
and then converts it to UTC for storage.
@@ -60,7 +63,12 @@ def convert_to_utc(date, project_id, is_start_date=False):
# If it's an start date, add one minute
if is_start_date:
localized_datetime += timedelta(minutes=1)
localized_datetime += timedelta(minutes=0, seconds=1)
# If it's start an end date are equal, add 23 hours, 59 minutes, and 59 seconds
# to make it the end of the day
if is_start_date_end_date_equal:
localized_datetime += timedelta(hours=23, minutes=59, seconds=59)
# Convert the localized datetime to UTC
utc_datetime = localized_datetime.astimezone(pytz.utc)

76
packages/types/src/home.d.ts vendored Normal file
View 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;
};

View File

@@ -38,6 +38,7 @@ export * from "./timezone";
export * from "./activity";
export * from "./epics";
export * from "./charts";
export * from "./home";
// enterprise
export * from "./active-cycle";
export * from "./payment";

View 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>
</>
);
};

View 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;

View File

@@ -0,0 +1 @@
export const HomePageHeader = () => <></>;

View File

@@ -0,0 +1 @@
export * from "./widget";

View File

@@ -0,0 +1 @@
export const StickiesWidget = () => <></>;

View File

@@ -2,6 +2,7 @@ export * from "./ai";
export * from "./estimates";
export * from "./gantt-chart";
export * from "./issues";
export * from "./page";
export * from "./project";
export * from "./user-permissions";
export * from "./workspace";

1
web/ce/constants/page.ts Normal file
View File

@@ -0,0 +1 @@
export const ENABLE_MOVE_PAGE = false;

View File

@@ -16,6 +16,7 @@ import {
TIssueServiceType,
} from "@plane/types";
// plane web constants
import { TSORT_ORDER } from "@/constants/common";
import { EActivityFilterType } from "@/plane-web/constants/issues";
// services
import { IssueActivityService } from "@/services/issue";
@@ -36,20 +37,17 @@ export interface IIssueActivityStoreActions {
export interface IIssueActivityStore extends IIssueActivityStoreActions {
// observables
sortOrder: "asc" | "desc";
loader: TActivityLoader;
activities: TIssueActivityIdMap;
activityMap: TIssueActivityMap;
// helper methods
getActivitiesByIssueId: (issueId: string) => string[] | undefined;
getActivityById: (activityId: string) => TIssueActivity | undefined;
getActivityCommentByIssueId: (issueId: string) => TIssueActivityComment[] | undefined;
toggleSortOrder: () => void;
getActivityCommentByIssueId: (issueId: string, sortOrder: TSORT_ORDER) => TIssueActivityComment[] | undefined;
}
export class IssueActivityStore implements IIssueActivityStore {
// observables
sortOrder: "asc" | "desc" = "asc";
loader: TActivityLoader = "fetch";
activities: TIssueActivityIdMap = {};
activityMap: TIssueActivityMap = {};
@@ -64,13 +62,11 @@ export class IssueActivityStore implements IIssueActivityStore {
) {
makeObservable(this, {
// observables
sortOrder: observable.ref,
loader: observable.ref,
activities: observable,
activityMap: observable,
// actions
fetchActivities: action,
toggleSortOrder: action,
});
this.serviceType = serviceType;
// services
@@ -88,7 +84,7 @@ export class IssueActivityStore implements IIssueActivityStore {
return this.activityMap[activityId] ?? undefined;
};
getActivityCommentByIssueId = computedFn((issueId: string) => {
getActivityCommentByIssueId = computedFn((issueId: string, sortOrder: TSORT_ORDER) => {
if (!issueId) return undefined;
let activityComments: TIssueActivityComment[] = [];
@@ -119,15 +115,11 @@ export class IssueActivityStore implements IIssueActivityStore {
});
});
activityComments = orderBy(activityComments, (e) => new Date(e.created_at || 0), this.sortOrder);
activityComments = orderBy(activityComments, (e) => new Date(e.created_at || 0), sortOrder);
return activityComments;
});
toggleSortOrder = () => {
this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc";
};
// actions
public async fetchActivities(
workspaceSlug: string,

View File

@@ -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}

View File

@@ -1,9 +1,14 @@
"use client";
import { observer } from "mobx-react";
// ui
import { Loader } from "@plane/ui";
// components
import { ChartDataType, IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart";
import { IBlockUpdateData } from "@/components/gantt-chart";
// hooks
import { useTimeLineChart } from "@/hooks/use-timeline-chart";
//
import { ETimeLineTypeType } from "../../contexts";
import { GanttDnDHOC } from "../gantt-dnd-HOC";
import { handleOrderChange } from "../utils";
import { ModulesSidebarBlock } from "./block";
@@ -12,13 +17,14 @@ import { ModulesSidebarBlock } from "./block";
type Props = {
title: string;
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock;
blockIds: string[];
enableReorder: boolean;
};
export const ModuleGanttSidebar: React.FC<Props> = (props) => {
const { blockUpdateHandler, blockIds, getBlockById, enableReorder } = props;
export const ModuleGanttSidebar: React.FC<Props> = observer((props) => {
const { blockUpdateHandler, blockIds, enableReorder } = props;
const { getBlockById } = useTimeLineChart(ETimeLineTypeType.MODULE);
const handleOnDrop = (
draggingBlockId: string | undefined,
@@ -52,4 +58,4 @@ export const ModuleGanttSidebar: React.FC<Props> = (props) => {
)}
</div>
);
};
});

View 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>
);
});

View File

@@ -0,0 +1,4 @@
export * from "./widgets";
export * from "./home-dashboard-widgets";
export * from "./project-empty-state";
export * from "./root";

View 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>
);
});

View 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);
}}
/>
)}
</>
)}
</>
);
});

View 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>
);
};

View File

@@ -0,0 +1 @@
export * from "./root";

View 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>
);
};

View File

@@ -0,0 +1,4 @@
export * from "./empty-states";
export * from "./loaders";
export * from "./recents";
export * from "./empty-states";

View 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>
);
};

View File

@@ -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>
);
});

View File

@@ -0,0 +1,3 @@
export * from "./root";
export * from "./links";
export * from "./link-detail";

View 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>
);
});

View 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>
);
});

View 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>
</>
);
});

View 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 };
};

View File

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

View 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];
};

View 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>
);

View 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>
);

View 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>
);
});

View File

@@ -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>
);
});

View 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>
);
});

View 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>
);
};

View 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;
};

View 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>
);
});

View 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>
);
});

View 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 });
}}
/>
);
};

View 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}`);
}}
/>
);
};

View 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`);
}}
/>
);
};

View File

@@ -1,5 +1,7 @@
import { FC } from "react";
import { observer } from "mobx-react";
// constants
import { TSORT_ORDER } from "@/constants/common";
// hooks
import { useIssueDetail } from "@/hooks/store";
// plane web components
@@ -21,18 +23,27 @@ type TIssueActivityCommentRoot = {
activityOperations: TActivityOperations;
showAccessSpecifier?: boolean;
disabled?: boolean;
sortOrder: TSORT_ORDER;
};
export const IssueActivityCommentRoot: FC<TIssueActivityCommentRoot> = observer((props) => {
const { workspaceSlug, issueId, selectedFilters, activityOperations, showAccessSpecifier, projectId, disabled } =
props;
const {
workspaceSlug,
issueId,
selectedFilters,
activityOperations,
showAccessSpecifier,
projectId,
disabled,
sortOrder,
} = props;
// hooks
const {
activity: { getActivityCommentByIssueId },
comment: {},
} = useIssueDetail();
const activityComments = getActivityCommentByIssueId(issueId);
const activityComments = getActivityCommentByIssueId(issueId, sortOrder);
if (!activityComments || (activityComments && activityComments.length <= 0)) return <></>;

View File

@@ -26,6 +26,9 @@ export const ActivityFilter: FC<TActivityFilter> = observer((props) => {
className="relative"
>
<span className="text-custom-text-200">Filters</span>
{selectedFilters.length < filterOptions.length && (
<span className="absolute h-2 w-2 -right-0.5 -top-0.5 bg-custom-primary-100 rounded-full" />
)}
</Button>
}
panelClassName="p-2 rounded-md border border-custom-border-200 bg-custom-background-100"

View File

@@ -1,7 +1,8 @@
"use client";
import { FC, useMemo, useState } from "react";
import { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { useLocalStorage } from "@plane/hooks";
// types
import { TFileSignedURLResponse, TIssueComment } from "@plane/types";
import { EFileAssetType } from "@plane/types/src/enums";
@@ -10,6 +11,8 @@ import { TOAST_TYPE, setToast } from "@plane/ui";
// components
import { IssueCommentCreate } from "@/components/issues";
import { ActivitySortRoot, IssueActivityCommentRoot } from "@/components/issues/issue-detail";
// constants
import { TSORT_ORDER } from "@/constants/common";
// hooks
import { useIssueDetail, useProject, useUser, useUserPermissions } from "@/hooks/store";
// plane web components
@@ -38,12 +41,14 @@ export type TActivityOperations = {
export const IssueActivity: FC<TIssueActivity> = observer((props) => {
const { workspaceSlug, projectId, issueId, disabled = false, isIntakeIssue = false } = props;
// state
const [selectedFilters, setSelectedFilters] = useState<TActivityFilters[]>(defaultActivityFilters);
// hooks
const { setValue: setFilterValue, storedValue: selectedFilters } = useLocalStorage(
"issue_activity_filters",
defaultActivityFilters
);
const { setValue: setSortOrder, storedValue: sortOrder } = useLocalStorage("activity_sort_order", TSORT_ORDER.ASC);
const {
issue: { getIssueById },
activity: { sortOrder, toggleSortOrder },
createComment,
updateComment,
removeComment,
@@ -60,14 +65,20 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
const isWorklogButtonEnabled = !isIntakeIssue && !isGuest && (isAdmin || isAssigned);
// toggle filter
const toggleFilter = (filter: TActivityFilters) => {
setSelectedFilters((prevFilters) => {
if (prevFilters.includes(filter)) {
if (prevFilters.length === 1) return prevFilters; // Ensure at least one filter is applied
return prevFilters.filter((f) => f !== filter);
} else {
return [...prevFilters, filter];
}
});
if (!selectedFilters) return;
let _filters = [];
if (selectedFilters.includes(filter)) {
if (selectedFilters.length === 1) return selectedFilters; // Ensure at least one filter is applied
_filters = selectedFilters.filter((f) => f !== filter);
} else {
_filters = [...selectedFilters, filter];
}
setFilterValue(_filters);
};
const toggleSortOrder = () => {
setSortOrder(sortOrder === TSORT_ORDER.ASC ? TSORT_ORDER.DESC : TSORT_ORDER.ASC);
};
const activityOperations: TActivityOperations = useMemo(
@@ -163,9 +174,9 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
disabled={disabled}
/>
)}
<ActivitySortRoot sortOrder={sortOrder} toggleSort={toggleSortOrder} />
<ActivitySortRoot sortOrder={sortOrder || TSORT_ORDER.ASC} toggleSort={toggleSortOrder} />
<ActivityFilterRoot
selectedFilters={selectedFilters}
selectedFilters={selectedFilters || defaultActivityFilters}
toggleFilter={toggleFilter}
isIntakeIssue={isIntakeIssue}
projectId={projectId}
@@ -181,10 +192,11 @@ export const IssueActivity: FC<TIssueActivity> = observer((props) => {
projectId={projectId}
workspaceSlug={workspaceSlug}
issueId={issueId}
selectedFilters={selectedFilters}
selectedFilters={selectedFilters || defaultActivityFilters}
activityOperations={activityOperations}
showAccessSpecifier={!!project.anchor}
disabled={disabled}
sortOrder={sortOrder || TSORT_ORDER.ASC}
/>
{!disabled && (
<IssueCommentCreate

View File

@@ -3,11 +3,12 @@
import { FC, memo } from "react";
import { ArrowUpWideNarrow, ArrowDownWideNarrow } from "lucide-react";
import { getButtonStyling } from "@plane/ui";
import { TSORT_ORDER } from "@/constants/common";
// helpers
import { cn } from "@/helpers/common.helper";
export type TActivitySortRoot = {
sortOrder: "asc" | "desc";
sortOrder: TSORT_ORDER;
toggleSort: () => void;
className?: string;
iconClassName?: string;

View File

@@ -63,7 +63,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,
})}
>

View File

@@ -28,6 +28,8 @@ import { cn } from "@/helpers/common.helper";
import { usePageOperations } from "@/hooks/use-page-operations";
// plane web components
import { MovePageModal } from "@/plane-web/components/pages";
// plane web constants
import { ENABLE_MOVE_PAGE } from "@/plane-web/constants";
// store types
import { TPageInstance } from "@/store/pages/base-page";
@@ -132,7 +134,7 @@ export const PageActions: React.FC<Props> = observer((props) => {
action: () => setMovePageModal(true),
title: "Move",
icon: FileOutput,
shouldRender: canCurrentUserMovePage,
shouldRender: canCurrentUserMovePage && ENABLE_MOVE_PAGE,
},
];
if (extraOptions) {

View File

@@ -104,6 +104,7 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
"full-screen",
"copy-link",
"make-a-copy",
"move",
"toggle-lock",
"toggle-access",
"archive-restore",

View File

@@ -28,3 +28,8 @@ export const PROGRESS_STATE_GROUPS_DETAILS = [
color: "#A3A3A3",
},
];
export enum TSORT_ORDER {
ASC = "asc",
DESC = "desc",
}

View 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;
};

View File

@@ -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,

View File

@@ -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(

View File

@@ -171,4 +171,14 @@ export class ProjectPageService extends APIService {
throw error?.response?.data;
});
}
async move(workspaceSlug: string, projectId: string, pageId: string, newProjectId: string): Promise<void> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/move/`, {
new_project_id: newProjectId,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@@ -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;
});
}
}

View File

@@ -306,5 +306,15 @@ export class ProjectPageStore implements IProjectPageStore {
* @param {string} pageId
* @param {string} newProjectId
*/
movePage = async (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => {};
movePage = async (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => {
try {
await this.service.move(workspaceSlug, projectId, pageId, newProjectId);
runInAction(() => {
unset(this.data, [pageId]);
});
} catch (error) {
console.error("Unable to move page", error);
throw error;
}
};
}

View 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;
}
};
}

View File

@@ -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);

View 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];
});
};
}

View File

@@ -1 +1,2 @@
export * from "ce/components/stickies";
export * from "./action-bar";

1
web/ee/constants/page.ts Normal file
View File

@@ -0,0 +1 @@
export * from "ce/constants/page";