snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
This commit is contained in:
115
apps/web/core/components/home/home-dashboard-widgets.tsx
Normal file
115
apps/web/core/components/home/home-dashboard-widgets.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { THomeWidgetKeys, THomeWidgetProps } from "@plane/types";
|
||||
// assets
|
||||
import darkWidgetsAsset from "@/app/assets/empty-state/dashboard/widgets-dark.webp?url";
|
||||
import lightWidgetsAsset from "@/app/assets/empty-state/dashboard/widgets-light.webp?url";
|
||||
// components
|
||||
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
// local imports
|
||||
import { StickiesWidget } from "../stickies/widget";
|
||||
import { HomeLoader, NoProjectsEmptyState, RecentActivityWidget } from "./widgets";
|
||||
import { DashboardQuickLinks } from "./widgets/links";
|
||||
import { ManageWidgetsModal } from "./widgets/manage";
|
||||
|
||||
export const HOME_WIDGETS_LIST: {
|
||||
[key in THomeWidgetKeys]: {
|
||||
component: React.FC<THomeWidgetProps> | null;
|
||||
fullWidth: boolean;
|
||||
title: string;
|
||||
};
|
||||
} = {
|
||||
quick_links: {
|
||||
component: DashboardQuickLinks,
|
||||
fullWidth: false,
|
||||
title: "home.quick_links.title_plural",
|
||||
},
|
||||
recents: {
|
||||
component: RecentActivityWidget,
|
||||
fullWidth: false,
|
||||
title: "home.recents.title",
|
||||
},
|
||||
my_stickies: {
|
||||
component: StickiesWidget,
|
||||
fullWidth: false,
|
||||
title: "stickies.title",
|
||||
},
|
||||
new_at_plane: {
|
||||
component: null,
|
||||
fullWidth: false,
|
||||
title: "home.new_at_plane.title",
|
||||
},
|
||||
quick_tutorial: {
|
||||
component: null,
|
||||
fullWidth: false,
|
||||
title: "home.quick_tutorial.title",
|
||||
},
|
||||
};
|
||||
|
||||
export const DashboardWidgets = observer(function DashboardWidgets() {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// navigation
|
||||
const pathname = usePathname();
|
||||
// theme hook
|
||||
const { resolvedTheme } = useTheme();
|
||||
// store hooks
|
||||
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets, isAnyWidgetEnabled, loading } =
|
||||
useHome();
|
||||
const { loader } = useProject();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const noWidgetsResolvedPath = resolvedTheme === "light" ? lightWidgetsAsset : darkWidgetsAsset;
|
||||
|
||||
// derived values
|
||||
const isWikiApp = pathname.includes(`/${workspaceSlug.toString()}/pages`);
|
||||
if (!workspaceSlug) return null;
|
||||
if (loading || loader !== "loaded") return <HomeLoader />;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col gap-7">
|
||||
<ManageWidgetsModal
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
isModalOpen={showWidgetSettings}
|
||||
handleOnClose={() => toggleWidgetSettings(false)}
|
||||
/>
|
||||
{!isWikiApp && <NoProjectsEmptyState />}
|
||||
|
||||
{isAnyWidgetEnabled ? (
|
||||
<div className="flex flex-col">
|
||||
{orderedWidgets.map((key) => {
|
||||
const WidgetComponent = HOME_WIDGETS_LIST[key]?.component;
|
||||
const isEnabled = widgetsMap[key]?.is_enabled;
|
||||
if (!WidgetComponent || !isEnabled) return null;
|
||||
return (
|
||||
<div key={key} className="py-4">
|
||||
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<SimpleEmptyState
|
||||
title={t("home.empty.widgets.title")}
|
||||
description={t("home.empty.widgets.description")}
|
||||
assetPath={noWidgetsResolvedPath}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
9
apps/web/core/components/home/index.ts
Normal file
9
apps/web/core/components/home/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./widgets";
|
||||
export * from "./home-dashboard-widgets";
|
||||
export * from "./root";
|
||||
66
apps/web/core/components/home/root.tsx
Normal file
66
apps/web/core/components/home/root.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { ContentWrapper } from "@plane/ui";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { useUserProfile, useUser } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { TourRoot } from "@/components/onboarding/tour/root";
|
||||
// local imports
|
||||
import { DashboardWidgets } from "./home-dashboard-widgets";
|
||||
import { UserGreetingsView } from "./user-greetings";
|
||||
import { HomePeekOverviewsRoot } from "../issues/peek-overview/peek-overviews";
|
||||
|
||||
export const WorkspaceHomeView = observer(function WorkspaceHomeView() {
|
||||
// store hooks
|
||||
const { workspaceSlug } = useParams();
|
||||
const { data: currentUser } = useUser();
|
||||
const { data: currentUserProfile, updateTourCompleted } = useUserProfile();
|
||||
const { fetchWidgets } = useHome();
|
||||
|
||||
useSWR(
|
||||
workspaceSlug ? `HOME_DASHBOARD_WIDGETS_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchWidgets(workspaceSlug?.toString()) : null,
|
||||
{
|
||||
revalidateIfStale: true,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: true,
|
||||
}
|
||||
);
|
||||
|
||||
const handleTourCompleted = async () => {
|
||||
try {
|
||||
await updateTourCompleted();
|
||||
} catch (error) {
|
||||
console.error("Error updating tour completed", error);
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: refactor loader implementation
|
||||
return (
|
||||
<>
|
||||
{currentUserProfile && !currentUserProfile.is_tour_completed && (
|
||||
<div className="fixed top-0 left-0 z-20 grid h-full w-full place-items-center overflow-y-auto bg-backdrop transition-opacity">
|
||||
<TourRoot onComplete={handleTourCompleted} />
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<HomePeekOverviewsRoot />
|
||||
<ContentWrapper className="mx-auto scrollbar-hide gap-6 bg-surface-1 px-page-x">
|
||||
<div className="mx-auto w-full max-w-[800px]">
|
||||
{currentUser && <UserGreetingsView user={currentUser} />}
|
||||
<DashboardWidgets />
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
});
|
||||
61
apps/web/core/components/home/user-greetings.tsx
Normal file
61
apps/web/core/components/home/user-greetings.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { IUser } from "@plane/types";
|
||||
// plane ui
|
||||
// hooks
|
||||
import { useCurrentTime } from "@/hooks/use-current-time";
|
||||
|
||||
export interface IUserGreetingsView {
|
||||
user: IUser;
|
||||
}
|
||||
|
||||
export function UserGreetingsView(props: IUserGreetingsView) {
|
||||
const { user } = props;
|
||||
// current time hook
|
||||
const { currentTime } = useCurrentTime();
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
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="my-6 flex flex-col items-center">
|
||||
<h2 className="text-center text-20 font-semibold">
|
||||
{t("good")} {t(greeting)}, {user?.first_name} {user?.last_name}
|
||||
</h2>
|
||||
<h5 className="flex items-center gap-2 font-medium text-placeholder">
|
||||
<div>{greeting === "morning" ? "🌤️" : greeting === "afternoon" ? "🌥️" : "🌙️"}</div>
|
||||
<div>
|
||||
{weekDay}, {date} {timeString}
|
||||
</div>
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
apps/web/core/components/home/widgets/empty-states/index.ts
Normal file
10
apps/web/core/components/home/widgets/empty-states/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./links";
|
||||
export * from "./no-projects";
|
||||
export * from "./recents";
|
||||
export * from "./stickies";
|
||||
21
apps/web/core/components/home/widgets/empty-states/links.tsx
Normal file
21
apps/web/core/components/home/widgets/empty-states/links.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmptyStateCompact } from "@plane/propel/empty-state";
|
||||
|
||||
export function LinksEmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center rounded-lg bg-layer-1 py-10">
|
||||
<EmptyStateCompact
|
||||
assetKey="link"
|
||||
assetClassName="w-20 h-20"
|
||||
title={t("workspace_empty_state.home_widget_quick_links.title")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
// mobx
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { CloseOutline, MembersOutline, ProjectsOutline, TickOutline } from "@makeplane/propel/icons";
|
||||
// plane ui
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { cn, getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web constants
|
||||
|
||||
export const NoProjectsEmptyState = observer(function NoProjectsEmptyState() {
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { data: currentUser } = useUser();
|
||||
const { joinedProjectIds } = useProject();
|
||||
const { currentWorkspace: activeWorkspace } = useWorkspace();
|
||||
// local storage
|
||||
const { storedValue, setValue } = useLocalStorage(`quickstart-guide-${workspaceSlug}`, {
|
||||
hide: false,
|
||||
visited_members: false,
|
||||
visited_workspace: false,
|
||||
visited_profile: false,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const canCreateProject = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
|
||||
const EMPTY_STATE_DATA = [
|
||||
{
|
||||
id: "create-project",
|
||||
title: "home.empty.create_project.title",
|
||||
description: "home.empty.create_project.description",
|
||||
icon: <ProjectsOutline className="size-4" />,
|
||||
flag: "projects",
|
||||
cta: {
|
||||
text: "home.empty.create_project.cta",
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
if (!canCreateProject) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleCreateProjectModal(true);
|
||||
},
|
||||
disabled: !canCreateProject,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "invite-team",
|
||||
title: "home.empty.invite_team.title",
|
||||
description: "home.empty.invite_team.description",
|
||||
icon: <MembersOutline className="size-4" />,
|
||||
flag: "visited_members",
|
||||
cta: {
|
||||
text: "home.empty.invite_team.cta",
|
||||
link: `/${workspaceSlug}/settings/members`,
|
||||
disabled: !isWorkspaceAdmin,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "configure-workspace",
|
||||
title: "home.empty.configure_workspace.title",
|
||||
description: "home.empty.configure_workspace.description",
|
||||
icon: <ProjectsOutline className="size-4" />,
|
||||
flag: "visited_workspace",
|
||||
cta: {
|
||||
text: "home.empty.configure_workspace.cta",
|
||||
link: "settings",
|
||||
disabled: !isWorkspaceAdmin,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "personalize-account",
|
||||
title: "home.empty.personalize_account.title",
|
||||
description: "home.empty.personalize_account.description",
|
||||
icon:
|
||||
currentUser?.avatar_url && currentUser?.avatar_url.trim() !== "" ? (
|
||||
<Link href={`/${workspaceSlug}/profile/${currentUser?.id}`}>
|
||||
<span className="relative flex size-4 items-center justify-center rounded-full p-4 text-on-color capitalize">
|
||||
<img
|
||||
src={getFileURL(currentUser?.avatar_url)}
|
||||
className="absolute top-0 left-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 size-4 items-center justify-center rounded-full bg-[#028375] p-4 text-13 text-on-color capitalize">
|
||||
{(currentUser?.email ?? currentUser?.display_name ?? "?")[0]}
|
||||
</span>
|
||||
</Link>
|
||||
),
|
||||
flag: "visited_profile",
|
||||
cta: {
|
||||
text: "home.empty.personalize_account.cta",
|
||||
link: `/settings/profile/general`,
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
const isComplete = (type: string) => {
|
||||
switch (type) {
|
||||
case "projects":
|
||||
return joinedProjectIds?.length > 0;
|
||||
case "visited_members":
|
||||
return (activeWorkspace?.total_members || 0) >= 2;
|
||||
case "visited_workspace":
|
||||
return storedValue?.visited_workspace;
|
||||
case "visited_profile":
|
||||
return storedValue?.visited_profile;
|
||||
}
|
||||
};
|
||||
|
||||
if (storedValue?.hide || (joinedProjectIds?.length > 0 && (activeWorkspace?.total_members || 0) >= 2)) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-14 font-semibold text-tertiary">{t("home.empty.quickstart_guide")}</div>
|
||||
<button
|
||||
className="flex items-center gap-1 text-13 font-medium text-tertiary"
|
||||
onClick={() => {
|
||||
if (!storedValue) return;
|
||||
setValue({ ...storedValue, hide: true });
|
||||
}}
|
||||
>
|
||||
<CloseOutline className="size-4" />
|
||||
{t("home.empty.not_right_now")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{EMPTY_STATE_DATA.map((item) => {
|
||||
const isStateComplete = isComplete(item.flag);
|
||||
return (
|
||||
<div key={item.id} className="flex flex-col rounded-xl border border-subtle bg-layer-2 p-4">
|
||||
<div
|
||||
className={cn("mb-3 grid size-9 place-items-center rounded-full bg-surface-2 text-placeholder", {
|
||||
"bg-accent-primary/10 text-accent-primary": !isStateComplete,
|
||||
})}
|
||||
>
|
||||
<span className="my-auto text-24">{item.icon}</span>
|
||||
</div>
|
||||
<h3 className="mb-2 text-13 font-medium text-primary">{t(item.title)}</h3>
|
||||
<p className="mb-2 text-11 text-tertiary">{t(item.description)}</p>
|
||||
{isStateComplete ? (
|
||||
<div className="flex w-fit items-center gap-2 rounded-full bg-[#17a34a] p-1">
|
||||
<TickOutline className="size-3 text-accent-primary text-on-color" />
|
||||
</div>
|
||||
) : (
|
||||
!item.cta.disabled &&
|
||||
(item.cta.link ? (
|
||||
<Link
|
||||
href={item.cta.link}
|
||||
onClick={(e) => {
|
||||
if (!storedValue) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
setValue({
|
||||
...storedValue,
|
||||
[item.flag]: true,
|
||||
});
|
||||
}}
|
||||
className={cn("text-13 font-medium text-accent-primary hover:text-accent-secondary", {})}
|
||||
>
|
||||
{t(item.cta.text)}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-left text-13 font-medium text-accent-primary hover:text-accent-secondary"
|
||||
onClick={item.cta.onClick}
|
||||
>
|
||||
{t(item.cta.text)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmptyStateCompact } from "@plane/propel/empty-state";
|
||||
import type { CompactAssetType } from "@plane/propel/empty-state";
|
||||
|
||||
const getDisplayContent = (type: string): { assetKey: CompactAssetType; text: string } => {
|
||||
switch (type) {
|
||||
case "project":
|
||||
return {
|
||||
assetKey: "project",
|
||||
text: "home.recents.empty.project",
|
||||
};
|
||||
case "page":
|
||||
return {
|
||||
assetKey: "note",
|
||||
text: "home.recents.empty.page",
|
||||
};
|
||||
case "issue":
|
||||
return {
|
||||
assetKey: "work-item",
|
||||
text: "home.recents.empty.issue",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
assetKey: "work-item",
|
||||
text: "home.recents.empty.default",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function RecentsEmptyState({ type }: { type: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { assetKey, text } = getDisplayContent(type);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center rounded-lg bg-layer-1 py-10">
|
||||
<EmptyStateCompact assetKey={assetKey} assetClassName="size-20" title={t(text)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmptyStateCompact } from "@plane/propel/empty-state";
|
||||
|
||||
export function StickiesEmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center rounded-lg bg-layer-1 py-10">
|
||||
<EmptyStateCompact assetKey="note" assetClassName="size-20" title={t("stickies.empty_state.simple")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
apps/web/core/components/home/widgets/index.ts
Normal file
9
apps/web/core/components/home/widgets/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./empty-states";
|
||||
export * from "./loaders";
|
||||
export * from "./recents";
|
||||
28
apps/web/core/components/home/widgets/links/action.tsx
Normal file
28
apps/web/core/components/home/widgets/links/action.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AddOutline } from "@makeplane/propel/icons";
|
||||
|
||||
type TProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
export function AddLink(props: TProps) {
|
||||
const { onClick } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn btn-primary flex h-[56px] w-[230px] gap-4 rounded-md border-[0.5px] border-subtle bg-surface-1 px-4"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="my-auto h-8 w-8 rounded-sm bg-layer-1/40 p-2">
|
||||
<AddOutline className="h-4 w-4 text-tertiary" />
|
||||
</div>
|
||||
<div className="my-auto text-13 font-medium">{t("home.quick_links.add")}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane types
|
||||
// plane ui
|
||||
import { Field } from "@makeplane/propel/components/field";
|
||||
import { Input, InputGroup } from "@makeplane/propel/components/input";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { TLinkEditableFields } from "@plane/types";
|
||||
import { ModalCore } from "@plane/ui";
|
||||
import type { 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;
|
||||
};
|
||||
|
||||
const defaultValues: TLinkCreateFormFieldOptions = {
|
||||
title: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const LinkCreateUpdateModal = observer(function LinkCreateUpdateModal(props: TLinkCreateEditModal) {
|
||||
// props
|
||||
const { isModalOpen, handleOnClose, linkOperations, preloadedData } = props;
|
||||
// react hook form
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
} = useForm<TLinkCreateFormFieldOptions>({
|
||||
defaultValues,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onClose = () => {
|
||||
if (handleOnClose) handleOnClose();
|
||||
};
|
||||
|
||||
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-18 font-medium text-secondary">
|
||||
{preloadedData?.id ? t("update") : t("add")} {t("home.quick_links.title")}
|
||||
</h3>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="url" className="mb-2 text-14 font-medium text-secondary">
|
||||
{t("link.modal.url.text")}
|
||||
<span className="block text-10">{t("required")}</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: t("link.modal.url.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Field name="url" invalid={Boolean(errors.url)}>
|
||||
<InputGroup size="2xl">
|
||||
<Input
|
||||
size="2xl"
|
||||
id="url"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
placeholder={t("link.modal.url.placeholder")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
{errors.url && <span className="text-11 text-danger-primary">{t("link.modal.url.required")}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-2 text-14 font-medium text-secondary">
|
||||
{t("link.modal.title.text")}
|
||||
<span className="block text-10">{t("optional")}</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Field name="title" invalid={Boolean(errors.title)}>
|
||||
<InputGroup size="2xl">
|
||||
<Input
|
||||
size="2xl"
|
||||
id="title"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
placeholder={t("link.modal.title.placeholder")}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-subtle px-5 py-4">
|
||||
<Button variant="secondary" size="lg" onClick={onClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" type="submit" loading={isSubmitting}>
|
||||
{preloadedData?.id ? (isSubmitting ? t("updating") : t("update")) : isSubmitting ? t("adding") : t("add")}{" "}
|
||||
{t("home.quick_links.title")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
9
apps/web/core/components/home/widgets/links/index.ts
Normal file
9
apps/web/core/components/home/widgets/links/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
export * from "./links";
|
||||
export * from "./link-detail";
|
||||
109
apps/web/core/components/home/widgets/links/link-detail.tsx
Normal file
109
apps/web/core/components/home/widgets/links/link-detail.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { DeleteOutline, EditOutline, LinkOutline, NewTabOutline } from "@makeplane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { LinkItemBlock } from "@plane/ui";
|
||||
// plane utils
|
||||
import { copyTextToClipboard } from "@plane/utils";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
// types
|
||||
import type { TLinkOperations } from "./use-links";
|
||||
|
||||
export type TProjectLinkDetail = {
|
||||
linkId: string;
|
||||
linkOperations: TLinkOperations;
|
||||
};
|
||||
|
||||
export const ProjectLinkDetail = observer(function ProjectLinkDetail(props: TProjectLinkDetail) {
|
||||
// props
|
||||
const { linkId, linkOperations } = props;
|
||||
// hooks
|
||||
const {
|
||||
quickLinks: { getLinkById, toggleLinkModal, setLinkData },
|
||||
} = useHome();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const linkDetail = getLinkById(linkId);
|
||||
const linkUrl = linkDetail?.url;
|
||||
|
||||
// handlers
|
||||
const handleEdit = useCallback(
|
||||
(modalToggle: boolean) => {
|
||||
toggleLinkModal(modalToggle);
|
||||
setLinkData(linkDetail);
|
||||
},
|
||||
[linkDetail, setLinkData, toggleLinkModal]
|
||||
);
|
||||
|
||||
const handleCopyText = useCallback(() => {
|
||||
if (!linkUrl) return;
|
||||
copyTextToClipboard(linkUrl).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("link_copied"),
|
||||
message: t("view_link_copied_to_clipboard"),
|
||||
});
|
||||
});
|
||||
}, [linkUrl, t]);
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
window.open(linkUrl, "_blank", "noopener,noreferrer");
|
||||
}, [linkUrl]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
linkOperations.remove(linkId);
|
||||
}, [linkId, linkOperations]);
|
||||
|
||||
// derived values
|
||||
const menuItems = useMemo<TContextMenuItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: "edit",
|
||||
action: () => handleEdit(true),
|
||||
title: t("edit"),
|
||||
icon: EditOutline,
|
||||
},
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: t("open_in_new_tab"),
|
||||
icon: NewTabOutline,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: t("copy_link"),
|
||||
icon: LinkOutline,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: handleDelete,
|
||||
title: t("delete"),
|
||||
icon: DeleteOutline,
|
||||
},
|
||||
],
|
||||
[handleEdit, handleOpenInNewTab, handleCopyText, handleDelete, t]
|
||||
);
|
||||
|
||||
if (!linkDetail) return null;
|
||||
|
||||
return (
|
||||
<LinkItemBlock
|
||||
title={linkDetail.title || linkDetail.url}
|
||||
url={linkDetail.url}
|
||||
createdAt={linkDetail.created_at}
|
||||
menuItems={menuItems}
|
||||
onClick={handleOpenInNewTab}
|
||||
/>
|
||||
);
|
||||
});
|
||||
53
apps/web/core/components/home/widgets/links/links.tsx
Normal file
53
apps/web/core/components/home/widgets/links/links.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// computed
|
||||
import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { LinksEmptyState } from "../empty-states/links";
|
||||
import { EWidgetKeys, WidgetLoader } from "../loaders";
|
||||
import { ProjectLinkDetail } from "./link-detail";
|
||||
import type { TLinkOperations } from "./use-links";
|
||||
|
||||
export type TLinkOperationsModal = Exclude<TLinkOperations, "create">;
|
||||
|
||||
export type TProjectLinkList = {
|
||||
linkOperations: TLinkOperationsModal;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const ProjectLinkList = observer(function ProjectLinkList(props: TProjectLinkList) {
|
||||
// props
|
||||
const { linkOperations, workspaceSlug } = props;
|
||||
// hooks
|
||||
const {
|
||||
quickLinks: { getLinksByWorkspaceId },
|
||||
} = useHome();
|
||||
|
||||
const links = getLinksByWorkspaceId(workspaceSlug);
|
||||
|
||||
if (links === undefined) return <WidgetLoader widgetKey={EWidgetKeys.QUICK_LINKS} />;
|
||||
|
||||
if (links.length === 0) return <LinksEmptyState />;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<ContentOverflowWrapper
|
||||
maxHeight={150}
|
||||
containerClassName="box-border min-h-[30px] flex flex-col"
|
||||
fallback={<></>}
|
||||
buttonClassName="bg-surface-2/20"
|
||||
>
|
||||
<div className="mb-2 flex flex-1 flex-wrap gap-2">
|
||||
{links.map((linkId) => (
|
||||
<ProjectLinkDetail key={linkId} linkId={linkId} linkOperations={linkOperations} />
|
||||
))}
|
||||
</div>
|
||||
</ContentOverflowWrapper>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
66
apps/web/core/components/home/widgets/links/root.tsx
Normal file
66
apps/web/core/components/home/widgets/links/root.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AddOutline } from "@makeplane/propel/icons";
|
||||
import type { THomeWidgetProps } from "@plane/types";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { LinkCreateUpdateModal } from "./create-update-link-modal";
|
||||
import { ProjectLinkList } from "./links";
|
||||
import { useLinks } from "./use-links";
|
||||
|
||||
export const DashboardQuickLinks = observer(function DashboardQuickLinks(props: THomeWidgetProps) {
|
||||
const { workspaceSlug } = props;
|
||||
const { linkOperations } = useLinks(workspaceSlug);
|
||||
const {
|
||||
quickLinks: { isLinkModalOpen, toggleLinkModal, linkData, setLinkData, fetchLinks },
|
||||
} = useHome();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleCreateLinkModal = useCallback(() => {
|
||||
toggleLinkModal(true);
|
||||
setLinkData(undefined);
|
||||
}, [toggleLinkModal, setLinkData]);
|
||||
|
||||
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}
|
||||
/>
|
||||
<div className="mb-2">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-14 font-semibold text-tertiary">{t("home.quick_links.title_plural")}</div>
|
||||
<button
|
||||
onClick={handleCreateLinkModal}
|
||||
className="my-auto flex gap-1 text-13 font-medium text-accent-primary"
|
||||
>
|
||||
<AddOutline className="my-auto size-4" /> <span>{t("home.quick_links.add")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap">
|
||||
{/* rendering links */}
|
||||
<ProjectLinkList workspaceSlug={workspaceSlug} linkOperations={linkOperations} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
105
apps/web/core/components/home/widgets/links/use-links.tsx
Normal file
105
apps/web/core/components/home/widgets/links/use-links.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TProjectLink } from "@plane/types";
|
||||
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 { t } = useTranslation();
|
||||
|
||||
const linkOperations: TLinkOperations = useMemo(
|
||||
() => ({
|
||||
create: async (data: Partial<TProjectLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug) throw new Error("Missing required fields");
|
||||
await createLink(workspaceSlug, data);
|
||||
setToast({
|
||||
message: t("links.toasts.created.message"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("links.toasts.created.title"),
|
||||
});
|
||||
toggleLinkModal(false);
|
||||
} catch (error: any) {
|
||||
console.error("error", error?.data?.error);
|
||||
setToast({
|
||||
message: error?.data?.error ?? t("links.toasts.not_created.message"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("links.toasts.not_created.title"),
|
||||
});
|
||||
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: t("links.toasts.updated.message"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("links.toasts.updated.title"),
|
||||
});
|
||||
toggleLinkModal(false);
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
message: error?.data?.error ?? t("links.toasts.not_updated.message"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("links.toasts.not_updated.title"),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
remove: async (linkId: string) => {
|
||||
try {
|
||||
if (!workspaceSlug) throw new Error("Missing required fields");
|
||||
await removeLink(workspaceSlug, linkId);
|
||||
setToast({
|
||||
message: t("links.toasts.removed.message"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("links.toasts.removed.message"),
|
||||
});
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
message: error?.data?.error ?? t("links.toasts.not_removed.message"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("links.toasts.not_removed.title"),
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[workspaceSlug]
|
||||
);
|
||||
|
||||
const handleOnClose = () => {
|
||||
toggleLinkModal(false);
|
||||
};
|
||||
|
||||
return { linkOperations, handleOnClose, isLinkModalOpen, toggleLinkModal, linkData, setLinkData, fetchLinks };
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { range } from "lodash-es";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export function HomeLoader() {
|
||||
return (
|
||||
<>
|
||||
{range(3).map((index) => (
|
||||
<div key={index}>
|
||||
<div className="mb-2">
|
||||
<div className="mb-4 text-14 font-semibold text-tertiary">
|
||||
<Loader.Item height="20px" width="100px" />
|
||||
</div>
|
||||
<Loader className="flex h-[110px] w-full items-center justify-center gap-2 rounded-sm text-placeholder">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
8
apps/web/core/components/home/widgets/loaders/index.ts
Normal file
8
apps/web/core/components/home/widgets/loaders/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./loader";
|
||||
export * from "./home-loader";
|
||||
31
apps/web/core/components/home/widgets/loaders/loader.tsx
Normal file
31
apps/web/core/components/home/widgets/loaders/loader.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// components
|
||||
import { QuickLinksWidgetLoader } from "./quick-links";
|
||||
import { RecentActivityWidgetLoader } from "./recent-activity";
|
||||
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
widgetKey: EWidgetKeys;
|
||||
};
|
||||
|
||||
export enum EWidgetKeys {
|
||||
RECENT_ACTIVITY = "recent_activity",
|
||||
QUICK_LINKS = "quick_links",
|
||||
}
|
||||
|
||||
export function WidgetLoader(props: Props) {
|
||||
const { widgetKey } = props;
|
||||
|
||||
const loaders = {
|
||||
[EWidgetKeys.RECENT_ACTIVITY]: <RecentActivityWidgetLoader />,
|
||||
[EWidgetKeys.QUICK_LINKS]: <QuickLinksWidgetLoader />,
|
||||
};
|
||||
|
||||
return loaders[widgetKey];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { range } from "lodash-es";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export function QuickLinksWidgetLoader() {
|
||||
return (
|
||||
<Loader className="flex flex-wrap gap-2 rounded-xl bg-surface-1">
|
||||
{range(4).map((index) => (
|
||||
<Loader.Item key={index} height="56px" width="230px" />
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { range } from "lodash-es";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export function RecentActivityWidgetLoader() {
|
||||
return (
|
||||
<Loader className="space-y-6 rounded-xl px-2">
|
||||
{range(5).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="my-auto w-full flex-shrink-0 space-y-3">
|
||||
<Loader.Item height="15px" width="70%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
33
apps/web/core/components/home/widgets/manage/index.tsx
Normal file
33
apps/web/core/components/home/widgets/manage/index.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
// plane ui
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { WidgetList } from "./widget-list";
|
||||
|
||||
export type TProps = {
|
||||
workspaceSlug: string;
|
||||
isModalOpen: boolean;
|
||||
handleOnClose?: () => void;
|
||||
};
|
||||
|
||||
export const ManageWidgetsModal = observer(function ManageWidgetsModal(props: TProps) {
|
||||
// props
|
||||
const { workspaceSlug, isModalOpen, handleOnClose } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isModalOpen} handleClose={handleOnClose} width={EModalWidth.MD}>
|
||||
<div className="p-4">
|
||||
<div className="text-18 font-medium"> {t("home.manage_widgets")}</div>
|
||||
<WidgetList workspaceSlug={workspaceSlug} />
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { DragHandle } from "@plane/ui";
|
||||
// helper
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
sort_order: number | null;
|
||||
isDragging: boolean;
|
||||
};
|
||||
|
||||
export const WidgetItemDragHandle = observer(function WidgetItemDragHandle(props: Props) {
|
||||
const { isDragging } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("mr-2 flex cursor-grab items-center justify-center rounded-sm text-placeholder", {
|
||||
"cursor-grabbing": isDragging,
|
||||
})}
|
||||
>
|
||||
<DragHandle className="bg-transparent" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
150
apps/web/core/components/home/widgets/manage/widget-item.tsx
Normal file
150
apps/web/core/components/home/widgets/manage/widget-item.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import type {
|
||||
DropTargetRecord,
|
||||
DragLocationHistory,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
|
||||
import type { ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { draggable, dropTargetForElements } 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";
|
||||
import { useParams } from "next/navigation";
|
||||
import { createRoot } from "react-dom/client";
|
||||
// plane types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { InstructionType } from "@plane/types";
|
||||
// plane ui
|
||||
import { Switch } from "@makeplane/propel/components/switch";
|
||||
import { DropIndicator } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { HOME_WIDGETS_LIST } from "../../home-dashboard-widgets";
|
||||
import { WidgetItemDragHandle } from "./widget-item-drag-handle";
|
||||
import { getCanDrop, getInstructionFromPayload } from "./widget.helpers";
|
||||
|
||||
type Props = {
|
||||
widgetId: string;
|
||||
isLastChild: boolean;
|
||||
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
|
||||
handleToggle: (workspaceSlug: string, widgetKey: string, is_enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export const WidgetItem = observer(function WidgetItem(props: Props) {
|
||||
// props
|
||||
const { widgetId, isLastChild, handleDrop, handleToggle } = props;
|
||||
const { workspaceSlug } = useParams();
|
||||
//state
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
|
||||
//ref
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
// hooks
|
||||
const { widgetsMap } = useHome();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const widget = widgetsMap[widgetId];
|
||||
const widgetTitle = HOME_WIDGETS_LIST[widget.key]?.title;
|
||||
|
||||
// drag and drop
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
|
||||
if (!element) return;
|
||||
const initialData = { id: widget.key, isGroup: false };
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
dragHandle: element,
|
||||
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-sm bg-surface-1 p-1 pr-2 text-13">{widget.key}</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.key]);
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<DropIndicator isVisible={instruction === "reorder-above"} />
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={cn(
|
||||
"group/widget-item relative flex items-center justify-between rounded-sm px-2 py-2 text-13 font-medium hover:bg-layer-1",
|
||||
{
|
||||
"cursor-grabbing bg-layer-1": isDragging,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<WidgetItemDragHandle sort_order={widget.sort_order} isDragging={isDragging} />
|
||||
<div>{t(widgetTitle, { count: 1 })}</div>
|
||||
</div>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={widget.is_enabled}
|
||||
onCheckedChange={(enabled) => handleToggle(workspaceSlug.toString(), widget.key, enabled)}
|
||||
aria-label={t(widgetTitle, { count: 1 })}
|
||||
/>
|
||||
</div>
|
||||
{isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
70
apps/web/core/components/home/widgets/manage/widget-list.tsx
Normal file
70
apps/web/core/components/home/widgets/manage/widget-list.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type {
|
||||
DragLocationHistory,
|
||||
DropTargetRecord,
|
||||
ElementDragPayload,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { WidgetItem } from "./widget-item";
|
||||
import type { TargetData } from "./widget.helpers";
|
||||
import { getInstructionFromPayload } from "./widget.helpers";
|
||||
|
||||
export const WidgetList = observer(function WidgetList({ workspaceSlug }: { workspaceSlug: string }) {
|
||||
const { orderedWidgets, reorderWidget, toggleWidget } = useHome();
|
||||
const { t } = useTranslation();
|
||||
|
||||
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) {
|
||||
reorderWidget(workspaceSlug, sourceData.id, droppedId, instruction)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("toast.success"),
|
||||
message: t("home.widget.reordered_successfully"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("home.widget.reordering_failed"),
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-4">
|
||||
{orderedWidgets.map((widget, index) => (
|
||||
<WidgetItem
|
||||
key={widget}
|
||||
widgetId={widget}
|
||||
isLastChild={index === orderedWidgets.length - 1}
|
||||
handleDrop={handleDrop}
|
||||
handleToggle={toggleWidget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import type { InstructionType, IPragmaticPayloadLocation, TDropTarget, TWidgetEntityData } from "@plane/types";
|
||||
|
||||
export type TargetData = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
isGroup: boolean;
|
||||
isChild: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* extracts the Payload and translates the instruction for the current dropTarget based on drag and drop payload
|
||||
* @param dropTarget dropTarget for which the instruction is required
|
||||
* @param source the dragging widget data that is being dragged on the dropTarget
|
||||
* @param location location includes the data of all the dropTargets the source is being dragged on
|
||||
* @returns Instruction for dropTarget
|
||||
*/
|
||||
export const getInstructionFromPayload = (
|
||||
dropTarget: TDropTarget,
|
||||
source: TDropTarget,
|
||||
location: IPragmaticPayloadLocation
|
||||
): InstructionType | undefined => {
|
||||
const dropTargetData = dropTarget?.data as TargetData;
|
||||
const sourceData = source?.data as TargetData;
|
||||
const allDropTargets = location?.current?.dropTargets;
|
||||
|
||||
// if all the dropTargets are greater than 1 meaning the source is being dragged on a group and its child at the same time
|
||||
// and also if the dropTarget in question is also a group then, it should be a child of the current Droptarget
|
||||
if (allDropTargets?.length > 1 && dropTargetData?.isGroup) return "make-child";
|
||||
|
||||
if (!dropTargetData || !sourceData) return undefined;
|
||||
|
||||
let instruction = extractInstruction(dropTargetData)?.type;
|
||||
|
||||
// If the instruction is blocked then set an instruction based on if dropTarget it is a child or not
|
||||
if (instruction === "instruction-blocked") {
|
||||
instruction = dropTargetData.isChild ? "reorder-above" : "make-child";
|
||||
}
|
||||
|
||||
// if source that is being dragged is a group. A group cannon be a child of any other widget,
|
||||
// hence if current instruction is to be a child of dropTarget then reorder-above instead
|
||||
if (instruction === "make-child" && sourceData.isGroup) instruction = "reorder-above";
|
||||
|
||||
return instruction;
|
||||
};
|
||||
|
||||
/**
|
||||
* This provides a boolean to indicate if the widget can be dropped onto the droptarget
|
||||
* @param source
|
||||
* @param widget
|
||||
* @returns
|
||||
*/
|
||||
export const getCanDrop = (source: TDropTarget, widget: TWidgetEntityData | undefined) => {
|
||||
const sourceData = source?.data;
|
||||
|
||||
if (!sourceData) return false;
|
||||
|
||||
// a widget cannot be dropped on to itself
|
||||
if (sourceData.id === widget?.key) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
57
apps/web/core/components/home/widgets/recents/filters.tsx
Normal file
57
apps/web/core/components/home/widgets/recents/filters.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ChevronDownOutline } from "@makeplane/propel/icons";
|
||||
import type { TRecentActivityFilterKeys } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export type TFiltersDropdown = {
|
||||
className?: string;
|
||||
activeFilter: TRecentActivityFilterKeys;
|
||||
setActiveFilter: (filter: TRecentActivityFilterKeys) => void;
|
||||
filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode; i18n_key: string }[];
|
||||
};
|
||||
|
||||
export const FiltersDropdown = observer(function FiltersDropdown(props: TFiltersDropdown) {
|
||||
const { className, activeFilter, setActiveFilter, filters } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
function DropdownOptions() {
|
||||
return filters?.map((filter) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={filter.name}
|
||||
className="flex items-center gap-2 truncate text-secondary"
|
||||
onClick={() => {
|
||||
setActiveFilter(filter.name);
|
||||
}}
|
||||
>
|
||||
<div className="truncate text-11 font-medium capitalize">{t(filter.i18n_key)}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
));
|
||||
}
|
||||
|
||||
const title = activeFilter ? filters?.find((filter) => filter.name === activeFilter)?.i18n_key : "";
|
||||
return (
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className={cn("flex w-fit justify-center text-11 text-secondary", className)}
|
||||
placement="bottom-start"
|
||||
customButton={
|
||||
<button className="flex gap-1 rounded-sm border border-subtle px-2 py-1 capitalize hover:bg-layer-transparent-hover">
|
||||
<span className="my-auto text-13 font-medium">{t(title || "")}</span>
|
||||
<ChevronDownOutline className={cn("my-auto size-3 text-tertiary duration-300 hover:text-secondary")} />
|
||||
</button>
|
||||
}
|
||||
customButtonClassName="flex justify-center"
|
||||
closeOnSelect
|
||||
>
|
||||
<DropdownOptions />
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
111
apps/web/core/components/home/widgets/recents/index.tsx
Normal file
111
apps/web/core/components/home/widgets/recents/index.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// plane types
|
||||
import { PagesOutline, ProjectsOutline, WorkItemsOutline } from "@makeplane/propel/icons";
|
||||
import type { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
|
||||
// plane ui
|
||||
// components
|
||||
import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/services/workspace.service";
|
||||
import { RecentsEmptyState } from "../empty-states";
|
||||
import { EWidgetKeys, WidgetLoader } from "../loaders";
|
||||
import { FiltersDropdown } from "./filters";
|
||||
import { RecentIssue } from "./issue";
|
||||
import { RecentPage } from "./page";
|
||||
import { RecentProject } from "./project";
|
||||
|
||||
const WIDGET_KEY = EWidgetKeys.RECENT_ACTIVITY;
|
||||
const workspaceService = new WorkspaceService();
|
||||
const filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode; i18n_key: string }[] = [
|
||||
{ name: "all item", i18n_key: "home.recents.filters.all" },
|
||||
{ name: "issue", icon: <WorkItemsOutline className="h-4 w-4" />, i18n_key: "home.recents.filters.issues" },
|
||||
{ name: "page", icon: <PagesOutline height={16} width={16} />, i18n_key: "home.recents.filters.pages" },
|
||||
{ name: "project", icon: <ProjectsOutline height={16} width={16} />, i18n_key: "home.recents.filters.projects" },
|
||||
];
|
||||
|
||||
type TRecentWidgetProps = THomeWidgetProps & {
|
||||
presetFilter?: TRecentActivityFilterKeys;
|
||||
showFilterSelect?: boolean;
|
||||
};
|
||||
|
||||
export const RecentActivityWidget = observer(function RecentActivityWidget(props: TRecentWidgetProps) {
|
||||
const { presetFilter, showFilterSelect = true, workspaceSlug } = props;
|
||||
// states
|
||||
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(presetFilter ?? filters[0].name);
|
||||
const { t } = useTranslation();
|
||||
// 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":
|
||||
case "workspace_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 (
|
||||
<div ref={ref} className="max-h-[500px] overflow-y-scroll">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div>
|
||||
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<RecentsEmptyState type={filter} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContentOverflowWrapper
|
||||
maxHeight={415}
|
||||
containerClassName="box-border min-h-[250px]"
|
||||
fallback={<></>}
|
||||
buttonClassName="bg-surface-2/20"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-14 font-semibold text-tertiary">{t("home.recents.title")}</div>
|
||||
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
||||
</div>
|
||||
<div className="flex min-h-[250px] flex-col">
|
||||
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
|
||||
{!isLoading &&
|
||||
recents
|
||||
?.filter((recent) => recent.entity_data)
|
||||
.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
|
||||
</div>
|
||||
</ContentOverflowWrapper>
|
||||
);
|
||||
});
|
||||
144
apps/web/core/components/home/widgets/recents/issue.tsx
Normal file
144
apps/web/core/components/home/widgets/recents/issue.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import { PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
|
||||
import { WorkItemsOutline } from "@makeplane/propel/icons";
|
||||
import { Tooltip } from "@makeplane/propel/components/tooltip";
|
||||
import type { TActivityEntityData, TIssueEntityData } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// plane ui
|
||||
import { calculateTimeAgo, generateWorkItemLink } from "@plane/utils";
|
||||
// components
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
import { IssueIdentifier } from "@/components/issues/issue-detail/issue-identifier";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
|
||||
type BlockProps = {
|
||||
activity: TActivityEntityData;
|
||||
ref: React.RefObject<HTMLDivElement | null>;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
export const RecentIssue = observer(function RecentIssue(props: BlockProps) {
|
||||
const { activity, ref, workspaceSlug } = props;
|
||||
// hooks
|
||||
const { getStateById } = useProjectState();
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
const { setPeekIssue: setPeekEpic } = useIssueDetail(EIssueServiceType.EPICS);
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
// derived values
|
||||
const issueDetails: TIssueEntityData = activity.entity_data as TIssueEntityData;
|
||||
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
|
||||
|
||||
if (!issueDetails) return <></>;
|
||||
|
||||
const state = getStateById(issueDetails?.state);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId: issueDetails?.project_id,
|
||||
issueId: issueDetails?.id,
|
||||
projectIdentifier,
|
||||
sequenceId: issueDetails?.sequence_id,
|
||||
isEpic: issueDetails?.is_epic,
|
||||
});
|
||||
|
||||
const handlePeekOverview = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const peekDetails = {
|
||||
workspaceSlug,
|
||||
projectId: issueDetails?.project_id,
|
||||
issueId: activity.entity_data.id,
|
||||
};
|
||||
if (issueDetails?.is_epic) setPeekEpic(peekDetails);
|
||||
else setPeekIssue(peekDetails);
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
id={`issue-${issueDetails?.id}`}
|
||||
itemLink={workItemLink}
|
||||
title={issueDetails?.name}
|
||||
prependTitleElement={
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{issueDetails.type ? (
|
||||
<IssueIdentifier
|
||||
size="lg"
|
||||
issueTypeId={issueDetails?.type}
|
||||
projectId={issueDetails?.project_id || ""}
|
||||
projectIdentifier={issueDetails?.project_identifier || ""}
|
||||
issueSequenceId={issueDetails?.sequence_id || ""}
|
||||
variant="tertiary"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="grid size-8 flex-shrink-0 place-items-center rounded-sm bg-layer-2">
|
||||
<WorkItemsOutline className="size-4 text-tertiary" />
|
||||
</div>
|
||||
<div className="text-13 font-medium whitespace-nowrap text-placeholder">
|
||||
{issueDetails?.project_identifier}-{issueDetails?.sequence_id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
appendTitleElement={
|
||||
<div className="flex-shrink-0 text-11 font-medium text-placeholder">
|
||||
{calculateTimeAgo(activity.visited_at)}
|
||||
</div>
|
||||
}
|
||||
quickActionElement={
|
||||
<div className="flex gap-4">
|
||||
<Tooltip label={`State: ${state?.name ?? "State"}`} layout="stacked">
|
||||
<div>
|
||||
<StateGroupIcon
|
||||
stateGroup={state?.group ?? "backlog"}
|
||||
color={state?.color}
|
||||
className="my-auto h-4 w-4"
|
||||
percentage={state?.order}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip label={`Priority: ${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="my-auto border-none !px-2 py-3"
|
||||
itemClassName="my-auto"
|
||||
onItemClick={handlePeekOverview}
|
||||
preventDefaultProgress
|
||||
/>
|
||||
);
|
||||
});
|
||||
87
apps/web/core/components/home/widgets/recents/page.tsx
Normal file
87
apps/web/core/components/home/widgets/recents/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Avatar } from "@makeplane/propel/components/avatar";
|
||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import { PagesOutline } from "@makeplane/propel/icons";
|
||||
// plane import
|
||||
import type { TActivityEntityData, TPageEntityData } from "@plane/types";
|
||||
import { calculateTimeAgo, getFileURL, getPageName } from "@plane/utils";
|
||||
import { ListItem } from "@/components/core/list";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
|
||||
type BlockProps = {
|
||||
activity: TActivityEntityData;
|
||||
ref: React.RefObject<HTMLDivElement | null>;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export function RecentPage(props: BlockProps) {
|
||||
const { activity, ref, workspaceSlug } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const pageDetails = activity.entity_data as TPageEntityData;
|
||||
|
||||
if (!pageDetails) return <></>;
|
||||
|
||||
const ownerDetails = getUserDetails(pageDetails?.owned_by);
|
||||
const pageLink = pageDetails.project_id
|
||||
? `/${workspaceSlug}/projects/${pageDetails.project_id}/pages/${pageDetails.id}`
|
||||
: `/${workspaceSlug}/pages/${pageDetails.id}`;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
itemLink={pageLink}
|
||||
title={getPageName(pageDetails?.name)}
|
||||
prependTitleElement={
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<div className="grid size-8 flex-shrink-0 place-items-center rounded-sm bg-layer-2">
|
||||
{pageDetails?.logo_props?.in_use ? (
|
||||
<Logo logo={pageDetails?.logo_props} size={16} type="lucide" />
|
||||
) : (
|
||||
<PagesOutline className="size-4 text-tertiary" />
|
||||
)}
|
||||
</div>
|
||||
{pageDetails?.project_identifier && (
|
||||
<div className="text-13 font-medium whitespace-nowrap text-placeholder">
|
||||
{pageDetails?.project_identifier}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
appendTitleElement={
|
||||
<div className="flex-shrink-0 text-11 font-medium text-placeholder">
|
||||
{calculateTimeAgo(activity.visited_at)}
|
||||
</div>
|
||||
}
|
||||
quickActionElement={
|
||||
<div className="flex gap-4">
|
||||
<Avatar
|
||||
alt={ownerDetails?.display_name}
|
||||
fallback={ownerDetails?.display_name?.[0]?.toUpperCase()}
|
||||
src={getFileURL(ownerDetails?.avatar_url ?? "")}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
parentRef={ref}
|
||||
disableLink={false}
|
||||
className="my-auto border-none !px-2 py-3"
|
||||
itemClassName="my-auto bg-layer-transparent"
|
||||
onItemClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push(pageLink);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
85
apps/web/core/components/home/widgets/recents/project.tsx
Normal file
85
apps/web/core/components/home/widgets/recents/project.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
// plane types
|
||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import type { TActivityEntityData, TProjectEntityData } from "@plane/types";
|
||||
import { calculateTimeAgo } from "@plane/utils";
|
||||
// components
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
// helpers
|
||||
|
||||
type BlockProps = {
|
||||
activity: TActivityEntityData;
|
||||
ref: React.RefObject<HTMLDivElement | null>;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
export function RecentProject(props: BlockProps) {
|
||||
const { activity, ref, workspaceSlug } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// derived values
|
||||
const projectDetails: TProjectEntityData = activity.entity_data as TProjectEntityData;
|
||||
|
||||
if (!projectDetails) return <></>;
|
||||
|
||||
const projectLink = `/${workspaceSlug}/projects/${projectDetails?.id}/issues`;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
itemLink={projectLink}
|
||||
title={projectDetails?.name}
|
||||
prependTitleElement={
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<div className="grid size-8 flex-shrink-0 place-items-center rounded-sm bg-layer-2">
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
</div>
|
||||
<div className="text-13 font-medium whitespace-nowrap text-placeholder">{projectDetails?.identifier}</div>
|
||||
</div>
|
||||
}
|
||||
appendTitleElement={
|
||||
<div className="flex-shrink-0 text-11 font-medium text-placeholder">
|
||||
{calculateTimeAgo(activity.visited_at)}
|
||||
</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="my-auto border-none !px-2 py-3"
|
||||
itemClassName="my-auto"
|
||||
onItemClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push(projectLink);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user