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

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

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

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

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