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,185 @@
/**
* 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 Link from "next/link";
import { useParams } from "next/navigation";
import { ChatOutline, HistoryOutline } from "@makeplane/propel/icons";
// plane imports
import type { IUserActivityResponse } from "@plane/types";
import { calculateTimeAgo, getFileURL } from "@plane/utils";
// components
import { ActivityIcon, ActivityMessage, IssueLink } from "@/components/core/activity";
import { RichTextEditor } from "@/components/editor/rich-text";
import { ActivitySettingsLoader } from "@/components/ui/loader/settings/activity";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUser } from "@/hooks/store/user";
type Props = {
activity: IUserActivityResponse | undefined;
};
export const ActivityList = observer(function ActivityList(props: Props) {
const { activity } = props;
// params
const { workspaceSlug } = useParams();
// store hooks
const { data: currentUser } = useUser();
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString() ?? "")?.id ?? "";
// TODO: refactor this component
return (
<>
{activity ? (
<ul role="list">
{activity.results.map((activityItem) => {
if (activityItem.field === "comment")
return (
<div key={activityItem.id} className="mt-2">
<div className="relative flex items-start space-x-3">
<div className="relative px-1">
{activityItem.field ? (
activityItem.new_value === "restore" && (
<HistoryOutline className="h-3.5 w-3.5 text-secondary" />
)
) : activityItem.actor_detail.avatar_url && activityItem.actor_detail.avatar_url !== "" ? (
<img
src={getFileURL(activityItem.actor_detail.avatar_url)}
alt={activityItem.actor_detail.display_name}
height={30}
width={30}
className="bg-gray-500 grid h-7 w-7 place-items-center rounded-full border-2 border-white text-on-color"
/>
) : (
<div className="bg-gray-500 grid h-7 w-7 place-items-center rounded-full border-2 border-white text-on-color capitalize">
{activityItem.actor_detail.display_name?.[0]}
</div>
)}
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-layer-1 text-secondary ring-6 ring-white">
<ChatOutline className="h-6 w-6 !text-20 text-secondary" aria-hidden="true" />
</span>
</div>
<div className="min-w-0 flex-1">
<div>
<div className="text-11">
{activityItem.actor_detail.is_bot
? activityItem.actor_detail.first_name + " Bot"
: activityItem.actor_detail.display_name}
</div>
<p className="mt-0.5 text-11 text-secondary">
Commented {calculateTimeAgo(activityItem.created_at)}
</p>
</div>
<div className="issue-comments-section p-0">
<RichTextEditor
editable={false}
id={activityItem.id}
initialValue={
activityItem?.new_value !== ""
? (activityItem.new_value?.toString() as string)
: (activityItem.old_value?.toString() as string)
}
containerClassName="text-11 bg-surface-1"
workspaceId={workspaceId}
workspaceSlug={workspaceSlug?.toString() ?? ""}
projectId={activityItem.project}
/>
</div>
</div>
</div>
</div>
);
const message =
activityItem.verb === "created" &&
!["cycles", "modules", "attachment", "link", "estimate"].includes(
activityItem.field?.toString() as string
) &&
!activityItem.field ? (
<span>
created <IssueLink activity={activityItem} />
</span>
) : (
<ActivityMessage activity={activityItem} showIssue />
);
if ("field" in activityItem && activityItem.field !== "updated_by")
return (
<li key={activityItem.id}>
<div className="relative pb-1">
<div className="relative flex items-start space-x-2">
<>
<div>
<div className="relative mt-4 px-1.5">
<div className="mt-1.5">
<div className="flex h-6 w-6 items-center justify-center">
{activityItem.field ? (
activityItem.new_value === "restore" ? (
<HistoryOutline className="h-5 w-5 text-secondary" />
) : (
<ActivityIcon activity={activityItem} />
)
) : activityItem.actor_detail.avatar_url &&
activityItem.actor_detail.avatar_url !== "" ? (
<img
src={getFileURL(activityItem.actor_detail.avatar_url)}
alt={activityItem.actor_detail.display_name}
height={24}
width={24}
className="h-full w-full rounded-full object-cover"
/>
) : (
<div className="bg-gray-700 grid h-6 w-6 place-items-center rounded-full border-2 border-white text-11 text-on-color capitalize">
{activityItem.actor_detail.display_name?.[0]}
</div>
)}
</div>
</div>
</div>
</div>
<div className="min-w-0 flex-1 border-b border-subtle py-4">
<div className="text-13 break-words text-secondary">
{activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? (
<span className="text-gray font-medium">Plane</span>
) : activityItem.actor_detail.is_bot ? (
<span className="text-gray font-medium">{activityItem.actor_detail.first_name} Bot</span>
) : (
<Link
href={`/${activityItem.workspace_detail?.slug}/profile/${activityItem.actor_detail.id}`}
className="inline"
>
<span className="text-gray font-medium">
{currentUser?.id === activityItem.actor_detail.id
? "You"
: activityItem.actor_detail.display_name}
</span>
</Link>
)}{" "}
<div className="inline gap-1">
{message}{" "}
<span className="flex-shrink-0 whitespace-nowrap">
{calculateTimeAgo(activityItem.created_at)}
</span>
</div>
</div>
</div>
</>
</div>
</div>
</li>
);
})}
</ul>
) : (
<ActivitySettingsLoader />
)}
</>
);
});

View File

@@ -0,0 +1,65 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { useParams } from "next/navigation";
// services
// ui
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
// helpers
import { renderFormattedPayloadDate } from "@plane/utils";
import { UserService } from "@/services/user.service";
const userService = new UserService();
export function DownloadActivityButton() {
// states
const [isDownloading, setIsDownloading] = useState(false);
// router
const { workspaceSlug, userId } = useParams();
//hooks
const { t } = useTranslation();
const handleDownload = async () => {
const today = renderFormattedPayloadDate(new Date());
if (!workspaceSlug || !userId || !today) return;
setIsDownloading(true);
const csv = await userService
.downloadProfileActivity(workspaceSlug.toString(), userId.toString(), {
date: today,
})
.finally(() => setIsDownloading(false));
// create a Blob object
const blob = new Blob([csv], { type: "text/csv" });
// create URL for the Blob object
const url = window.URL.createObjectURL(blob);
// create a link element
const a = document.createElement("a");
a.href = url;
a.download = `profile-activity-${Date.now()}.csv`;
document.body.appendChild(a);
// simulate click on the link element to trigger download
a.click();
// cleanup
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
};
return (
<Button onClick={handleDownload} loading={isDownloading}>
{isDownloading ? t("profile.stats.recent_activity.button_loading") : t("profile.stats.recent_activity.button")}
</Button>
);
}

View File

@@ -0,0 +1,55 @@
/**
* 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 { useParams } from "next/navigation";
import useSWR from "swr";
// services
import { USER_PROFILE_ACTIVITY } from "@plane/constants";
import { UserService } from "@/services/user.service";
// components
import { ActivityList } from "./activity-list";
// fetch-keys
// services
const userService = new UserService();
type Props = {
cursor: string;
perPage: number;
updateResultsCount: (count: number) => void;
updateTotalPages: (count: number) => void;
};
export function WorkspaceActivityListPage(props: Props) {
const { cursor, perPage, updateResultsCount, updateTotalPages } = props;
// router
const { workspaceSlug, userId } = useParams();
const { data: userProfileActivity } = useSWR(
workspaceSlug && userId
? USER_PROFILE_ACTIVITY(workspaceSlug.toString(), userId.toString(), {
cursor,
})
: null,
workspaceSlug && userId
? () =>
userService.getUserProfileActivity(workspaceSlug.toString(), userId.toString(), {
cursor,
per_page: perPage,
})
: null
);
useEffect(() => {
if (!userProfileActivity) return;
updateTotalPages(userProfileActivity.total_pages);
updateResultsCount(userProfileActivity.results.length);
}, [updateResultsCount, updateTotalPages, userProfileActivity]);
return <ActivityList activity={userProfileActivity} />;
}

View File

@@ -0,0 +1,90 @@
/**
* 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";
// ui
import { useTranslation } from "@plane/i18n";
import { Avatar } from "@plane/propel/avatar";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { Loader, Card } from "@plane/ui";
import { calculateTimeAgo, getFileURL } from "@plane/utils";
// components
import { ActivityMessage, IssueLink } from "@/components/core/activity";
// constants
import { USER_PROFILE_ACTIVITY } from "@plane/constants";
// helpers
// hooks
import { useUser } from "@/hooks/store/user";
// services
import { UserService } from "@/services/user.service";
const userService = new UserService();
export const ProfileActivity = observer(function ProfileActivity() {
const { workspaceSlug, userId } = useParams();
// store hooks
const { data: currentUser } = useUser();
const { t } = useTranslation();
const { data: userProfileActivity } = useSWR(
workspaceSlug && userId ? USER_PROFILE_ACTIVITY(workspaceSlug.toString(), userId.toString(), {}) : null,
workspaceSlug && userId
? () =>
userService.getUserProfileActivity(workspaceSlug.toString(), userId.toString(), {
per_page: 10,
})
: null
);
return (
<div className="space-y-2">
<h3 className="text-16 font-medium">{t("profile.stats.recent_activity.title")}</h3>
<Card>
{!userProfileActivity ? (
<Loader className="space-y-5">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : Array.isArray(userProfileActivity.results) && userProfileActivity.results.length > 0 ? (
<div className="space-y-5">
{userProfileActivity.results.map((activity) => (
<div key={activity.id} className="flex gap-3">
<Avatar
name={activity.actor_detail?.display_name}
src={getFileURL(activity.actor_detail?.avatar_url)}
size="base"
shape="square"
/>
<div className="-mt-1 w-4/5 break-words">
<p className="inline text-13 text-secondary">
<span className="font-medium text-primary">
{currentUser?.id === activity.actor_detail?.id ? "You" : activity.actor_detail?.display_name}{" "}
</span>
{activity.field ? (
<ActivityMessage activity={activity} showIssue />
) : (
<span>
created <IssueLink activity={activity} />
</span>
)}
</p>
<p className="text-11 whitespace-nowrap text-secondary">{calculateTimeAgo(activity.created_at)}</p>
</div>
</div>
))}
</div>
) : (
<EmptyStateCompact title={t("no_data_yet")} assetKey="unknown" assetClassName="size-20" />
)}
</Card>
</div>
);
});

View File

@@ -0,0 +1,86 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import { useTranslation } from "@plane/i18n";
import { BarChart } from "@plane/propel/charts/bar-chart";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import type { IUserProfileData } from "@plane/types";
import { Loader, Card } from "@plane/ui";
import { capitalizeFirstLetter } from "@plane/utils";
type Props = {
userProfile: IUserProfileData | undefined;
};
const priorityColors = {
urgent: "#991b1b",
high: "#ef4444",
medium: "#f59e0b",
low: "#16a34a",
none: "#e5e5e5",
};
export function ProfilePriorityDistribution({ userProfile }: Props) {
const { t } = useTranslation();
return (
<div className="flex flex-col space-y-2">
<h3 className="text-16 font-medium">{t("profile.stats.priority_distribution.title")}</h3>
{userProfile ? (
<Card>
{userProfile.priority_distribution.length > 0 ? (
<BarChart
className="h-[300px] w-full"
margin={{ top: 20, right: 30, bottom: 5, left: 0 }}
data={userProfile.priority_distribution.map((priority) => ({
key: priority.priority ?? "None",
name: capitalizeFirstLetter(priority.priority ?? "None"),
count: priority.priority_count,
}))}
bars={[
{
key: "count",
label: "Count",
stackId: "bar-one",
fill: (payload: any) => priorityColors[payload.key as keyof typeof priorityColors], // TODO: fix types
textClassName: "",
showPercentage: false,
showTopBorderRadius: () => true,
showBottomBorderRadius: () => true,
},
]}
xAxis={{
key: "name",
label: t("common.priority"),
}}
yAxis={{
key: "count",
label: "",
}}
barSize={20}
/>
) : (
<EmptyStateCompact
assetKey="priority"
assetClassName="size-20"
title={t("workspace_empty_state.your_work_by_priority.title")}
/>
)}
</Card>
) : (
<div className="grid place-items-center p-7">
<Loader className="flex items-end gap-12">
<Loader.Item width="30px" height="200px" />
<Loader.Item width="30px" height="150px" />
<Loader.Item width="30px" height="250px" />
<Loader.Item width="30px" height="150px" />
<Loader.Item width="30px" height="100px" />
</Loader>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,90 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import { STATE_GROUPS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { PieChart } from "@plane/propel/charts/pie-chart";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import type { IUserProfileData, IUserStateDistribution } from "@plane/types";
import { Card } from "@plane/ui";
import { capitalizeFirstLetter } from "@plane/utils";
type Props = {
stateDistribution: IUserStateDistribution[];
userProfile: IUserProfileData | undefined;
};
export function ProfileStateDistribution({ stateDistribution, userProfile }: Props) {
const { t } = useTranslation();
if (!userProfile) return null;
return (
<div className="flex flex-col space-y-2">
<h3 className="text-16 font-medium">{t("profile.stats.state_distribution.title")}</h3>
<Card className="h-full">
{userProfile.state_distribution.length > 0 ? (
<div className="grid h-[300px] w-full grid-cols-1 gap-x-6 md:grid-cols-2">
<PieChart
className="size-full"
dataKey="value"
margin={{
top: 0,
right: -10,
bottom: 12,
left: -10,
}}
data={
userProfile.state_distribution.map((group) => ({
id: group.state_group,
key: group.state_group,
value: group.state_count,
name: capitalizeFirstLetter(group.state_group),
color: STATE_GROUPS[group.state_group]?.color,
})) ?? []
}
cells={userProfile.state_distribution.map((group) => ({
key: group.state_group,
fill: STATE_GROUPS[group.state_group]?.color,
}))}
showTooltip
tooltipLabel="Count"
paddingAngle={5}
cornerRadius={4}
innerRadius="50%"
showLabel={false}
/>
<div className="flex items-center">
<div className="w-full space-y-4">
{stateDistribution.map((group) => (
<div key={group.state_group} className="flex items-center justify-between gap-2 text-11">
<div className="flex items-center gap-1.5">
<div
className="h-2.5 w-2.5 rounded-xs"
style={{
backgroundColor:
STATE_GROUPS[group.state_group]?.color ?? "var(--background-color-accent-primary)",
}}
/>
<div className="whitespace-nowrap">{STATE_GROUPS[group.state_group].label}</div>
</div>
<div>{group.state_count}</div>
</div>
))}
</div>
</div>
</div>
) : (
<EmptyStateCompact
assetKey="priority"
assetClassName="size-20"
title={t("workspace_empty_state.your_work_by_priority.title")}
/>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,76 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import Link from "next/link";
import { useParams } from "next/navigation";
// ui
import { useTranslation } from "@plane/i18n";
import { LayerStackIcon } from "@plane/propel/icons";
import { AddOutline, UserOutline } from "@makeplane/propel/icons";
import type { IUserProfileData } from "@plane/types";
import { Loader, Card, ECardSpacing, ECardDirection } from "@plane/ui";
// types
type Props = {
userProfile: IUserProfileData | undefined;
};
export function ProfileStats({ userProfile }: Props) {
const { workspaceSlug, userId } = useParams();
const { t } = useTranslation();
const overviewCards = [
{
icon: AddOutline,
route: "created",
i18n_title: "profile.stats.created",
value: userProfile?.created_issues ?? "...",
},
{
icon: UserOutline,
route: "assigned",
i18n_title: "profile.stats.assigned",
value: userProfile?.assigned_issues ?? "...",
},
{
icon: LayerStackIcon,
route: "subscribed",
i18n_title: "profile.stats.subscribed",
value: userProfile?.subscribed_issues ?? "...",
},
];
return (
<div className="space-y-2">
<h3 className="text-16 font-medium">{t("profile.stats.overview")}</h3>
{userProfile ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{overviewCards.map((card) => (
<Link key={card.route} href={`/${workspaceSlug}/profile/${userId}/${card.route}`}>
<Card direction={ECardDirection.ROW} spacing={ECardSpacing.SM} className="h-full">
<div className="grid h-11 w-11 place-items-center rounded-sm bg-surface-2">
<card.icon className="h-5 w-5" />
</div>
<div className="space-y-1">
<p className="text-13 text-placeholder">{t(card.i18n_title)}</p>
<p className="text-18 font-semibold">{card.value}</p>
</div>
</Card>
</Link>
))}
</div>
) : (
<Loader className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="80px" />
<Loader.Item height="80px" />
<Loader.Item height="80px" />
</Loader>
)}
</div>
);
}

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.
*/
// plane imports
import { STATE_GROUPS } from "@plane/constants";
// types
import { useTranslation } from "@plane/i18n";
import type { IUserStateDistribution } from "@plane/types";
import { Card, ECardDirection, ECardSpacing } from "@plane/ui";
// constants
type Props = {
stateDistribution: IUserStateDistribution[];
};
export function ProfileWorkload({ stateDistribution }: Props) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<h3 className="text-16 font-medium">{t("profile.stats.workload")}</h3>
<div className="grid grid-cols-1 justify-stretch gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{stateDistribution.map((group) => (
<div key={group.state_group}>
<a>
<Card direction={ECardDirection.ROW} spacing={ECardSpacing.SM}>
<div
className="my-2 h-3 w-3 rounded-xs"
style={{
backgroundColor: STATE_GROUPS[group.state_group].color,
}}
/>
<div className="flex-col space-y-1">
<span className="text-13 text-placeholder">
{group.state_group === "unstarted"
? "Not started"
: group.state_group === "started"
? "Working on"
: STATE_GROUPS[group.state_group].label}
</span>
<p className="text-18 font-semibold">{group.state_count}</p>
</div>
</Card>
</a>
</div>
))}
</div>
</div>
);
}

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 { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane constants
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
// i18n
import { useTranslation } from "@plane/i18n";
// types
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// components
import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters";
import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
export const ProfileIssuesFilter = observer(function ProfileIssuesFilter() {
// i18n
const { t } = useTranslation();
// router
const { workspaceSlug, userId: routeUserId } = useParams();
const userId = routeUserId ? routeUserId.toString() : undefined;
// store hook
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.PROFILE);
// derived values
const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !userId) return;
updateFilters(workspaceSlug.toString(), undefined, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, userId);
},
[workspaceSlug, updateFilters, userId]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !userId) return;
updateFilters(
workspaceSlug.toString(),
undefined,
EIssueFilterType.DISPLAY_FILTERS,
updatedDisplayFilter,
userId
);
},
[workspaceSlug, updateFilters, userId]
);
const handleDisplayProperties = useCallback(
(property: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !userId) return;
updateFilters(workspaceSlug.toString(), undefined, EIssueFilterType.DISPLAY_PROPERTIES, property, userId);
},
[workspaceSlug, updateFilters, userId]
);
return (
<div className="relative flex items-center justify-end gap-2">
<LayoutSelection
layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
{userId && <WorkItemFiltersToggle entityType={EIssuesStoreType.PROFILE} entityId={userId} />}
<FiltersDropdown title={t("common.display")} placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
/>
</FiltersDropdown>
</div>
);
});

View File

@@ -0,0 +1,82 @@
/**
* 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 } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane imports
import { ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { EIssuesStoreType } from "@plane/types";
// components
import { ProfileIssuesKanBanLayout } from "@/components/issues/issue-layouts/kanban/roots/profile-issues-root";
import { ProfileIssuesListLayout } from "@/components/issues/issue-layouts/list/roots/profile-issues-root";
import { IssuePeekOverview } from "@/components/issues/peek-overview";
import { WorkspaceLevelWorkItemFiltersHOC } from "@/components/work-item-filters/filters-hoc/workspace-level";
import { WorkItemFiltersRow } from "@/components/work-item-filters/filters-row";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { IssuesStoreContext } from "@/hooks/use-issue-layout-store";
type Props = {
type: "assigned" | "subscribed" | "created";
};
export const ProfileIssuesPage = observer(function ProfileIssuesPage(props: Props) {
const { type } = props;
const { workspaceSlug, userId } = useParams();
// store hooks
const {
issues: { setViewId },
issuesFilter: { issueFilters, fetchFilters, updateFilterExpression },
} = useIssues(EIssuesStoreType.PROFILE);
// derived values
const activeLayout = issueFilters?.displayFilters?.layout || undefined;
useEffect(() => {
if (setViewId) setViewId(type);
}, [type, setViewId]);
useSWR(
workspaceSlug && userId ? `CURRENT_WORKSPACE_PROFILE_ISSUES_${workspaceSlug}_${userId}` : null,
async () => {
if (workspaceSlug && userId) {
await fetchFilters(workspaceSlug, userId);
}
},
{ revalidateIfStale: false, revalidateOnFocus: false }
);
return (
<IssuesStoreContext.Provider value={EIssuesStoreType.PROFILE}>
<WorkspaceLevelWorkItemFiltersHOC
entityId={userId}
entityType={EIssuesStoreType.PROFILE}
filtersToShowByLayout={ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues.filters}
initialWorkItemFilters={issueFilters}
updateFilters={updateFilterExpression.bind(updateFilterExpression, workspaceSlug, userId)}
workspaceSlug={workspaceSlug}
>
{({ filter: profileWorkItemsFilter }) => (
<>
<div className="flex h-full w-full flex-col">
{profileWorkItemsFilter && <WorkItemFiltersRow filter={profileWorkItemsFilter} />}
<div className="relative h-full w-full overflow-auto">
{activeLayout === "list" ? (
<ProfileIssuesListLayout />
) : activeLayout === "kanban" ? (
<ProfileIssuesKanBanLayout />
) : null}
</div>
</div>
{/* peek overview */}
<IssuePeekOverview />
</>
)}
</WorkspaceLevelWorkItemFiltersHOC>
</IssuesStoreContext.Provider>
);
});

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 { cn } from "@plane/utils";
import { SidebarHamburgerToggle } from "@/components/core/sidebar/sidebar-menu-hamburger-toggle";
type Props = {
children: React.ReactNode;
className?: string;
};
export function ProfileSettingContentWrapper(props: Props) {
const { children, className = "" } = props;
return (
<div className="flex h-full flex-col">
<div className="block flex-shrink-0 border-b border-subtle p-4 md:hidden">
<SidebarHamburgerToggle />
</div>
<div
className={cn(
"vertical-scrollbar mx-auto scrollbar-md flex h-full w-full flex-col px-8 py-10 md:px-20 md:py-16 lg:px-36 xl:px-56",
className
)}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,296 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { IconButton } from "@plane/propel/icon-button";
import { ChevronDownOutline, EditOutline } from "@makeplane/propel/icons";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { IUserProfileProjectSegregation } from "@plane/types";
import { Loader } from "@plane/ui";
import { cn, renderFormattedDate, getFileURL } from "@plane/utils";
// components
import { CoverImage } from "@/components/common/cover-image";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUser } from "@/hooks/store/user";
import { usePlatformOS } from "@/hooks/use-platform-os";
// components
import { ProfileSidebarTime } from "./time";
type TProfileSidebar = {
userProjectsData: IUserProfileProjectSegregation | undefined;
className?: string;
};
export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSidebar) {
const { userProjectsData, className = "" } = props;
// refs
const ref = useRef<HTMLDivElement>(null);
// router
const { userId } = useParams();
// store hooks
const { data: currentUser } = useUser();
const { profileSidebarCollapsed, toggleProfileSidebar } = useAppTheme();
const { getProjectById } = useProject();
const { toggleProfileSettingsModal } = useCommandPalette();
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
// derived values
const userData = userProjectsData?.user_data;
useOutsideClickDetector(ref, () => {
if (profileSidebarCollapsed === false) {
if (window.innerWidth < 768) {
toggleProfileSidebar();
}
}
});
const userDetails = [
{
i18n_label: "profile.details.joined_on",
value: renderFormattedDate(userData?.date_joined ?? ""),
},
{
i18n_label: "profile.details.time_zone",
value: <ProfileSidebarTime timeZone={userData?.user_timezone} />,
},
];
useEffect(() => {
const handleToggleProfileSidebar = () => {
if (window && window.innerWidth < 768) {
toggleProfileSidebar(true);
}
if (window && profileSidebarCollapsed && window.innerWidth >= 768) {
toggleProfileSidebar(false);
}
};
window.addEventListener("resize", handleToggleProfileSidebar);
handleToggleProfileSidebar();
return () => window.removeEventListener("resize", handleToggleProfileSidebar);
}, []);
return (
<div
className={cn(
`vertical-scrollbar fixed z-5 scrollbar-md h-full w-full shrink-0 overflow-hidden overflow-y-auto border-l border-subtle bg-surface-1 shadow-raised-200 transition-all md:relative md:w-[300px]`,
className
)}
style={profileSidebarCollapsed ? { marginLeft: `${window?.innerWidth || 0}px` } : {}}
>
{userProjectsData ? (
<>
<div className="relative h-[110px]">
{currentUser?.id === userId && (
<div className="absolute top-3.5 right-3.5">
<IconButton
variant="secondary"
icon={EditOutline}
onClick={() =>
toggleProfileSettingsModal({
activeTab: "general",
isOpen: true,
})
}
/>
</div>
)}
<CoverImage
src={userData?.cover_image_url ?? undefined}
alt={userData?.display_name}
className="h-[110px] w-full"
showDefaultWhenEmpty
/>
<div className="absolute -bottom-[26px] left-5 h-[52px] w-[52px] rounded-sm">
{userData?.avatar_url && userData?.avatar_url !== "" ? (
<img
src={getFileURL(userData?.avatar_url)}
alt={userData?.display_name}
className="h-full w-full rounded-sm object-cover"
/>
) : (
<div className="flex h-[52px] w-[52px] items-center justify-center rounded-sm bg-accent-primary text-on-color capitalize">
{userData?.first_name?.[0]}
</div>
)}
</div>
</div>
<div className="px-5">
<div className="mt-[38px]">
<h4 className="text-16 font-semibold">
{userData?.first_name} {userData?.last_name}
</h4>
<h6 className="text-13 text-secondary">({userData?.display_name})</h6>
</div>
<div className="mt-6 space-y-5">
{userDetails.map((detail) => (
<div key={detail.i18n_label} className="flex items-center gap-4 text-13">
<div className="w-2/5 flex-shrink-0 text-secondary">{t(detail.i18n_label)}</div>
<div className="w-3/5 font-medium break-words">{detail.value}</div>
</div>
))}
</div>
<div className="mt-9 divide-y divide-subtle">
{userProjectsData.project_data.map((project, index) => {
const projectDetails = getProjectById(project.id);
const totalIssues =
project.created_issues + project.assigned_issues + project.pending_issues + project.completed_issues;
const completedIssuePercentage =
project.assigned_issues === 0
? 0
: Math.round((project.completed_issues / project.assigned_issues) * 100);
if (!projectDetails) return null;
return (
<Disclosure key={project.id} as="div" className={`${index === 0 ? "pb-3" : "py-3"}`}>
{({ open }) => (
<div className="w-full">
<Disclosure.Button className="flex w-full items-center justify-between gap-2">
<div className="flex w-3/4 items-center gap-2">
<span className="grid h-7 w-7 flex-shrink-0 place-items-center">
<Logo logo={projectDetails.logo_props} />
</span>
<div className="truncate text-13 font-medium break-words">{projectDetails.name}</div>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
{project.assigned_issues > 0 && (
<Tooltip label="Completion percentage" side="left" disabled={isMobile}>
<div
className={`rounded-sm px-1 py-0.5 text-11 font-medium ${
completedIssuePercentage <= 35
? "bg-danger-subtle text-danger-primary"
: completedIssuePercentage <= 70
? "bg-yellow-500/10 text-yellow-500"
: "bg-success-subtle text-success-primary"
}`}
>
{completedIssuePercentage}%
</div>
</Tooltip>
)}
<ChevronDownOutline className="h-4 w-4" />
</div>
</Disclosure.Button>
<Transition
as="div"
show={open}
enter="transition duration-100 ease-out"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform opacity-100"
leaveTo="transform opacity-0"
>
<Disclosure.Panel className="mt-5 pl-9">
{totalIssues > 0 && (
<div className="flex items-center gap-0.5">
<div
className="h-1 rounded-sm"
style={{
backgroundColor: "#203b80",
width: `${(project.created_issues / totalIssues) * 100}%`,
}}
/>
<div
className="h-1 rounded-sm"
style={{
backgroundColor: "#3f76ff",
width: `${(project.assigned_issues / totalIssues) * 100}%`,
}}
/>
<div
className="h-1 rounded-sm"
style={{
backgroundColor: "#f59e0b",
width: `${(project.pending_issues / totalIssues) * 100}%`,
}}
/>
<div
className="h-1 rounded-sm"
style={{
backgroundColor: "#16a34a",
width: `${(project.completed_issues / totalIssues) * 100}%`,
}}
/>
</div>
)}
<div className="mt-7 space-y-5 text-13 text-secondary">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="h-2.5 w-2.5 rounded-xs bg-[#203b80]" />
Created
</div>
<div className="font-medium">
{project.created_issues} {t("issues")}
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="h-2.5 w-2.5 rounded-xs bg-[#3f76ff]" />
Assigned
</div>
<div className="font-medium">
{project.assigned_issues} {t("issues")}
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="h-2.5 w-2.5 rounded-xs bg-[#f59e0b]" />
Due
</div>
<div className="font-medium">
{project.pending_issues} {t("issues")}
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<div className="h-2.5 w-2.5 rounded-xs bg-[#16a34a]" />
Completed
</div>
<div className="font-medium">
{project.completed_issues} {t("issues")}
</div>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
);
})}
</div>
</div>
</>
) : (
<Loader className="space-y-7 px-5">
<Loader.Item height="130px" />
<div className="space-y-5">
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
<Loader.Item height="20px" />
</div>
</Loader>
)}
</div>
);
});

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.
*/
import { observer } from "mobx-react";
// plane imports
import { START_OF_THE_WEEK_OPTIONS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { EStartOfTheWeek } from "@plane/types";
import { CustomSelect } from "@plane/ui";
// components
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
import { useUserProfile } from "@/hooks/store/user";
const getStartOfWeekLabel = (startOfWeek: EStartOfTheWeek) =>
START_OF_THE_WEEK_OPTIONS.find((option) => option.value === startOfWeek)?.label;
export const StartOfWeekPreference = observer(function StartOfWeekPreference(props: {
option: { title: string; description: string };
}) {
// hooks
const { data: userProfile, updateUserProfile } = useUserProfile();
const handleStartOfWeekChange = async (val: number) => {
try {
await updateUserProfile({ start_of_the_week: val });
setToast({ type: TOAST_TYPE.SUCCESS, title: "Success", message: "First day of the week updated successfully" });
} catch (_error) {
setToast({ type: TOAST_TYPE.ERROR, title: "Update failed", message: "Please try again later." });
}
};
return (
<SettingsControlItem
title={props.option.title}
description={props.option.description}
control={
<CustomSelect
value={userProfile.start_of_the_week}
label={getStartOfWeekLabel(userProfile.start_of_the_week)}
onChange={handleStartOfWeekChange}
buttonClassName="border border-subtle-1"
input
maxHeight="lg"
placement="bottom-end"
>
<>
{START_OF_THE_WEEK_OPTIONS.map((day) => (
<CustomSelect.Option key={day.value} value={day.value}>
{day.label}
</CustomSelect.Option>
))}
</>
</CustomSelect>
}
/>
);
});

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.
*/
// hooks
import { useCurrentTime } from "@/hooks/use-current-time";
type Props = {
timeZone: string | undefined;
};
export function ProfileSidebarTime(props: Props) {
const { timeZone } = props;
// current time hook
const { currentTime } = useCurrentTime();
// Create a date object for the current time in the specified timezone
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: timeZone,
hour12: false, // Use 24-hour format
hour: "2-digit",
minute: "2-digit",
});
const timeString = formatter.format(currentTime);
return (
<span>
{timeString} <span className="text-secondary">{timeZone}</span>
</span>
);
}