feat: init
This commit is contained in:
177
apps/web/core/components/profile/activity/activity-list.tsx
Normal file
177
apps/web/core/components/profile/activity/activity-list.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { History, MessageSquare } from "lucide-react";
|
||||
// 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: React.FC<Props> = observer((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" && <History className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
) : 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="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 capitalize text-white">
|
||||
{activityItem.actor_detail.display_name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="ring-6 flex h-6 w-6 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
|
||||
<MessageSquare className="h-6 w-6 !text-2xl text-custom-text-200" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>
|
||||
<div className="text-xs">
|
||||
{activityItem.actor_detail.is_bot
|
||||
? activityItem.actor_detail.first_name + " Bot"
|
||||
: activityItem.actor_detail.display_name}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-custom-text-200">
|
||||
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-xs bg-custom-background-100"
|
||||
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 px-1.5 mt-4">
|
||||
<div className="mt-1.5">
|
||||
<div className="flex h-6 w-6 items-center justify-center">
|
||||
{activityItem.field ? (
|
||||
activityItem.new_value === "restore" ? (
|
||||
<History className="h-5 w-5 text-custom-text-200" />
|
||||
) : (
|
||||
<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="grid h-6 w-6 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs capitalize text-white">
|
||||
{activityItem.actor_detail.display_name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 border-b border-custom-border-100 py-4">
|
||||
<div className="break-words text-sm text-custom-text-200">
|
||||
{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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
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 const 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
// icons
|
||||
import { History, MessageSquare } from "lucide-react";
|
||||
import { calculateTimeAgo, getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { ActivityIcon, ActivityMessage } from "@/components/core/activity";
|
||||
import { RichTextEditor } from "@/components/editor/rich-text";
|
||||
import { ActivitySettingsLoader } from "@/components/ui/loader/settings/activity";
|
||||
// constants
|
||||
import { USER_ACTIVITY } from "@/constants/fetch-keys";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
const userService = new UserService();
|
||||
|
||||
type Props = {
|
||||
cursor: string;
|
||||
perPage: number;
|
||||
updateResultsCount: (count: number) => void;
|
||||
updateTotalPages: (count: number) => void;
|
||||
updateEmptyState: (state: boolean) => void;
|
||||
};
|
||||
|
||||
export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
|
||||
const { cursor, perPage, updateResultsCount, updateTotalPages, updateEmptyState } = props;
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
const { data: userProfileActivity } = useSWR(
|
||||
USER_ACTIVITY({
|
||||
cursor,
|
||||
}),
|
||||
() =>
|
||||
userService.getUserActivity({
|
||||
cursor,
|
||||
per_page: perPage,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfileActivity) return;
|
||||
|
||||
// if no results found then show empty state
|
||||
if (userProfileActivity.total_results === 0) updateEmptyState(true);
|
||||
|
||||
updateTotalPages(userProfileActivity.total_pages);
|
||||
updateResultsCount(userProfileActivity.results.length);
|
||||
}, [updateResultsCount, updateTotalPages, userProfileActivity, updateEmptyState]);
|
||||
|
||||
// TODO: refactor this component
|
||||
return (
|
||||
<>
|
||||
{userProfileActivity ? (
|
||||
<ul role="list">
|
||||
{userProfileActivity.results.map((activityItem: any) => {
|
||||
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" && <History className="h-3.5 w-3.5 text-custom-text-200" />
|
||||
) : 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="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 capitalize text-white">
|
||||
{activityItem.actor_detail.display_name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="ring-6 flex h-6 w-6 p-2 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
|
||||
<MessageSquare className="!text-2xl text-custom-text-200" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>
|
||||
<div className="text-xs">
|
||||
{activityItem.actor_detail.is_bot
|
||||
? activityItem.actor_detail.first_name + " Bot"
|
||||
: activityItem.actor_detail.display_name}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-custom-text-200">
|
||||
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 : activityItem.old_value
|
||||
}
|
||||
containerClassName="text-xs bg-custom-background-100"
|
||||
workspaceId={activityItem?.workspace_detail?.id?.toString() ?? ""}
|
||||
workspaceSlug={activityItem?.workspace_detail?.slug?.toString() ?? ""}
|
||||
projectId={activityItem.project ?? ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const message = <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 px-1.5 mt-4">
|
||||
<div className="mt-1.5">
|
||||
<div className="flex h-6 w-6 items-center justify-center">
|
||||
{activityItem.field ? (
|
||||
activityItem.new_value === "restore" ? (
|
||||
<History className="h-5 w-5 text-custom-text-200" />
|
||||
) : (
|
||||
<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="grid h-6 w-6 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs capitalize text-white">
|
||||
{activityItem.actor_detail.display_name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 border-b border-custom-border-100 py-4">
|
||||
<div className="break-words text-sm text-custom-text-200">
|
||||
{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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// services
|
||||
import { USER_PROFILE_ACTIVITY } from "@/constants/fetch-keys";
|
||||
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 const WorkspaceActivityListPage: React.FC<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} />;
|
||||
};
|
||||
410
apps/web/core/components/profile/form.tsx
Normal file
410
apps/web/core/components/profile/form.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { ChevronDown, CircleUserRound, InfoIcon } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast";
|
||||
import type { IUser, TUserProfile } from "@plane/types";
|
||||
import { Input } from "@plane/ui";
|
||||
import { cn, getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { DeactivateAccountModal } from "@/components/account/deactivate-account-modal";
|
||||
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
|
||||
import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal";
|
||||
// helpers
|
||||
import { captureSuccess, captureError } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useUser, useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
type TUserProfileForm = {
|
||||
avatar_url: string;
|
||||
cover_image: string;
|
||||
cover_image_asset: any;
|
||||
cover_image_url: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
language: string;
|
||||
user_timezone: string;
|
||||
};
|
||||
|
||||
export type TProfileFormProps = {
|
||||
user: IUser;
|
||||
profile: TUserProfile;
|
||||
};
|
||||
|
||||
export const ProfileForm = observer((props: TProfileFormProps) => {
|
||||
const { user, profile } = props;
|
||||
const { workspaceSlug } = useParams();
|
||||
// states
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isImageUploadModalOpen, setIsImageUploadModalOpen] = useState(false);
|
||||
const [deactivateAccountModal, setDeactivateAccountModal] = useState(false);
|
||||
// language support
|
||||
const { t } = useTranslation();
|
||||
// form info
|
||||
const {
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<TUserProfileForm>({
|
||||
defaultValues: {
|
||||
avatar_url: user.avatar_url || "",
|
||||
cover_image_asset: null,
|
||||
cover_image_url: user.cover_image_url || "",
|
||||
first_name: user.first_name || "",
|
||||
last_name: user.last_name || "",
|
||||
display_name: user.display_name || "",
|
||||
email: user.email || "",
|
||||
role: profile.role || "Product / Project Manager",
|
||||
language: profile.language || "en",
|
||||
user_timezone: user.user_timezone || "Asia/Kolkata",
|
||||
},
|
||||
});
|
||||
// derived values
|
||||
const userAvatar = watch("avatar_url");
|
||||
const userCover = watch("cover_image_url");
|
||||
// store hooks
|
||||
const { data: currentUser, updateCurrentUser } = useUser();
|
||||
const { updateUserProfile } = useUserProfile();
|
||||
|
||||
const handleProfilePictureDelete = async (url: string | null | undefined) => {
|
||||
if (!url) return;
|
||||
await updateCurrentUser({
|
||||
avatar_url: "",
|
||||
})
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Profile picture deleted successfully.",
|
||||
});
|
||||
setValue("avatar_url", "");
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "There was some error in deleting your profile picture. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsImageUploadModalOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (formData: TUserProfileForm) => {
|
||||
setIsLoading(true);
|
||||
const userPayload: Partial<IUser> = {
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
avatar_url: formData.avatar_url,
|
||||
display_name: formData?.display_name,
|
||||
};
|
||||
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
|
||||
if (formData.cover_image_url?.startsWith("http")) {
|
||||
userPayload.cover_image_url = formData.cover_image_url;
|
||||
userPayload.cover_image = formData.cover_image_url;
|
||||
userPayload.cover_image_asset = null;
|
||||
}
|
||||
|
||||
const profilePayload: Partial<TUserProfile> = {
|
||||
role: formData.role,
|
||||
};
|
||||
|
||||
const updateCurrentUserDetail = updateCurrentUser(userPayload).finally(() => setIsLoading(false));
|
||||
const updateCurrentUserProfile = updateUserProfile(profilePayload).finally(() => setIsLoading(false));
|
||||
|
||||
const promises = [updateCurrentUserDetail, updateCurrentUserProfile];
|
||||
const updateUserAndProfile = Promise.all(promises);
|
||||
|
||||
setPromiseToast(updateUserAndProfile, {
|
||||
loading: "Updating...",
|
||||
success: {
|
||||
title: "Success!",
|
||||
message: () => `Profile updated successfully.`,
|
||||
},
|
||||
error: {
|
||||
title: "Error!",
|
||||
message: () => `There was some error in updating your profile. Please try again.`,
|
||||
},
|
||||
});
|
||||
updateUserAndProfile
|
||||
.then(() => {
|
||||
captureSuccess({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.update_profile,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
captureError({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.update_profile,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeactivateAccountModal isOpen={deactivateAccountModal} onClose={() => setDeactivateAccountModal(false)} />
|
||||
<Controller
|
||||
control={control}
|
||||
name="avatar_url"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<UserImageUploadModal
|
||||
isOpen={isImageUploadModalOpen}
|
||||
onClose={() => setIsImageUploadModalOpen(false)}
|
||||
handleRemove={async () => await handleProfilePictureDelete(currentUser?.avatar_url)}
|
||||
onSuccess={(url) => {
|
||||
onChange(url);
|
||||
handleSubmit(onSubmit)();
|
||||
setIsImageUploadModalOpen(false);
|
||||
}}
|
||||
value={value && value.trim() !== "" ? value : null}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="w-full flex text-custom-primary-200 bg-custom-primary-100/10 rounded-md p-2 gap-2 items-center mb-4">
|
||||
<InfoIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="text-sm font-medium flex-1">{t("settings_moved_to_preferences")}</div>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/settings/account/preferences`}
|
||||
className={cn(getButtonStyling("neutral-primary", "sm"))}
|
||||
>
|
||||
{t("go_to_preferences")}
|
||||
</Link>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="w-full">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="relative h-44 w-full">
|
||||
<img
|
||||
src={userCover ? getFileURL(userCover) : "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"}
|
||||
className="h-44 w-full rounded-lg object-cover"
|
||||
alt={currentUser?.first_name ?? "Cover image"}
|
||||
/>
|
||||
<div className="absolute -bottom-6 left-6 flex items-end justify-between">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-custom-background-90">
|
||||
<button type="button" onClick={() => setIsImageUploadModalOpen(true)}>
|
||||
{!userAvatar || userAvatar === "" ? (
|
||||
<div className="h-16 w-16 rounded-md bg-custom-background-80 p-2">
|
||||
<CircleUserRound className="h-full w-full text-custom-text-200" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-16 w-16 overflow-hidden">
|
||||
<img
|
||||
src={getFileURL(userAvatar)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
|
||||
onClick={() => setIsImageUploadModalOpen(true)}
|
||||
alt={currentUser?.display_name}
|
||||
role="button"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-3 right-3 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
name="cover_image_url"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ImagePickerPopover
|
||||
label={t("change_cover")}
|
||||
onChange={(imageUrl) => onChange(imageUrl)}
|
||||
control={control}
|
||||
value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"}
|
||||
isProfileCover
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-center mt-6 flex justify-between">
|
||||
<div className="flex flex-col">
|
||||
<div className="item-center flex text-lg font-medium text-custom-text-200">
|
||||
<span>{`${watch("first_name")} ${watch("last_name")}`}</span>
|
||||
</div>
|
||||
<span className="text-sm text-custom-text-300 tracking-tight">{watch("email")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-x-6 gap-y-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm font-medium text-custom-text-200">
|
||||
{t("first_name")}
|
||||
<span className="text-red-500">*</span>
|
||||
</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="first_name"
|
||||
rules={{
|
||||
required: "Please enter first name",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.first_name)}
|
||||
placeholder="Enter your first name"
|
||||
className={`w-full rounded-md ${errors.first_name ? "border-red-500" : ""}`}
|
||||
maxLength={24}
|
||||
autoComplete="on"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.first_name && <span className="text-xs text-red-500">{errors.first_name.message}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm font-medium text-custom-text-200">{t("last_name")}</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="last_name"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.last_name)}
|
||||
placeholder="Enter your last name"
|
||||
className="w-full rounded-md"
|
||||
maxLength={24}
|
||||
autoComplete="on"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm font-medium text-custom-text-200">
|
||||
{t("display_name")}
|
||||
<span className="text-red-500">*</span>
|
||||
</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="display_name"
|
||||
rules={{
|
||||
required: "Display name is required.",
|
||||
validate: (value) => {
|
||||
if (value.trim().length < 1) return "Display name can't be empty.";
|
||||
if (value.split(" ").length > 1) return "Display name can't have two consecutive spaces.";
|
||||
if (value.replace(/\s/g, "").length < 1) return "Display name must be at least 1 character long.";
|
||||
if (value.replace(/\s/g, "").length > 20)
|
||||
return "Display name must be less than 20 characters long.";
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="display_name"
|
||||
name="display_name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors?.display_name)}
|
||||
placeholder="Enter your display name"
|
||||
className={`w-full ${errors?.display_name ? "border-red-500" : ""}`}
|
||||
maxLength={24}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.display_name && <span className="text-xs text-red-500">{errors?.display_name?.message}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm font-medium text-custom-text-200">
|
||||
{t("auth.common.email.label")}
|
||||
<span className="text-red-500">*</span>
|
||||
</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="email"
|
||||
rules={{
|
||||
required: "Email is required.",
|
||||
}}
|
||||
render={({ field: { value, ref } }) => (
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={value}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.email)}
|
||||
placeholder="Enter your email"
|
||||
className={`w-full cursor-not-allowed rounded-md !bg-custom-background-90 ${
|
||||
errors.email ? "border-red-500" : ""
|
||||
}`}
|
||||
autoComplete="on"
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between pt-6 pb-8">
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
data-ph-element={PROFILE_SETTINGS_TRACKER_ELEMENTS.SAVE_CHANGES_BUTTON}
|
||||
>
|
||||
{isLoading ? t("saving") : t("save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<Disclosure as="div" className="border-t border-custom-border-100 w-full">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4">
|
||||
<span className="text-lg font-medium tracking-tight">{t("deactivate_account")}</span>
|
||||
<ChevronDown className={`h-5 w-5 transition-all ${open ? "rotate-180" : ""}`} />
|
||||
</Disclosure.Button>
|
||||
<Transition
|
||||
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>
|
||||
<div className="flex flex-col gap-8">
|
||||
<span className="text-sm tracking-tight">{t("deactivate_account_description")}</span>
|
||||
<div>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => setDeactivateAccountModal(true)}
|
||||
data-ph-element={PROFILE_SETTINGS_TRACKER_ELEMENTS.DEACTIVATE_ACCOUNT_BUTTON}
|
||||
>
|
||||
{t("deactivate_account")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IUserEmailNotificationSettings } from "@plane/types";
|
||||
// ui
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
// services
|
||||
import { captureClick, captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
import { UserService } from "@/services/user.service";
|
||||
// types
|
||||
interface IEmailNotificationFormProps {
|
||||
data: IUserEmailNotificationSettings;
|
||||
}
|
||||
|
||||
// services
|
||||
const userService = new UserService();
|
||||
|
||||
export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) => {
|
||||
const { data } = props;
|
||||
const { t } = useTranslation();
|
||||
// form data
|
||||
const { control, reset } = useForm<IUserEmailNotificationSettings>({
|
||||
defaultValues: {
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSettingChange = async (key: keyof IUserEmailNotificationSettings, value: boolean) => {
|
||||
try {
|
||||
await userService.updateCurrentUserEmailNotificationSettings({
|
||||
[key]: value,
|
||||
});
|
||||
captureSuccess({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.notifications_updated,
|
||||
payload: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
title: t("success"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
message: t("email_notification_setting_updated_successfully"),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
captureError({
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.notifications_updated,
|
||||
payload: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
title: t("error"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: t("failed_to_update_email_notification_setting"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset(data);
|
||||
}, [reset, data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Notification Settings */}
|
||||
<div className="flex flex-col py-2 w-full">
|
||||
<div className="flex gap-2 items-center pt-2">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-base font-medium text-custom-text-100">{t("property_changes")}</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">{t("property_changes_description")}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Controller
|
||||
control={control}
|
||||
name="property_change"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
captureClick({
|
||||
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.PROPERTY_CHANGES_TOGGLE,
|
||||
});
|
||||
handleSettingChange("property_change", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center pt-6 pb-2">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-base font-medium text-custom-text-100">{t("state_change")}</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">{t("state_change_description")}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Controller
|
||||
control={control}
|
||||
name="state_change"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
captureClick({
|
||||
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.STATE_CHANGES_TOGGLE,
|
||||
});
|
||||
handleSettingChange("state_change", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center border-0 border-l-[3px] border-custom-border-300 pl-3">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-base font-medium text-custom-text-100">{t("issue_completed")}</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">{t("issue_completed_description")}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Controller
|
||||
control={control}
|
||||
name="issue_completed"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
handleSettingChange("issue_completed", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center pt-6">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-base font-medium text-custom-text-100">{t("comments")}</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">{t("comments_description")}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Controller
|
||||
control={control}
|
||||
name="comment"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
captureClick({
|
||||
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.COMMENTS_TOGGLE,
|
||||
});
|
||||
handleSettingChange("comment", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center pt-6">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-base font-medium text-custom-text-100">{t("mentions")}</div>
|
||||
<div className="text-sm font-normal text-custom-text-300">{t("mentions_description")}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<Controller
|
||||
control={control}
|
||||
name="mention"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
onChange(newValue);
|
||||
captureClick({
|
||||
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.MENTIONS_TOGGLE,
|
||||
});
|
||||
handleSettingChange("mention", newValue);
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
104
apps/web/core/components/profile/overview/activity.tsx
Normal file
104
apps/web/core/components/profile/overview/activity.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Loader, Card } from "@plane/ui";
|
||||
import { calculateTimeAgo, getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { ActivityMessage, IssueLink } from "@/components/core/activity";
|
||||
import { ProfileEmptyState } from "@/components/ui/profile-empty-state";
|
||||
// constants
|
||||
import { USER_PROFILE_ACTIVITY } from "@/constants/fetch-keys";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
// assets
|
||||
import recentActivityEmptyState from "@/public/empty-state/recent_activity.svg";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
export const ProfileActivity = observer(() => {
|
||||
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-lg font-medium">{t("profile.stats.recent_activity.title")}</h3>
|
||||
<Card>
|
||||
{userProfileActivity ? (
|
||||
userProfileActivity.results.length > 0 ? (
|
||||
<div className="space-y-5">
|
||||
{userProfileActivity.results.map((activity) => (
|
||||
<div key={activity.id} className="flex gap-3">
|
||||
<div className="flex-shrink-0 grid place-items-center overflow-hidden rounded h-6 w-6">
|
||||
{activity.actor_detail?.avatar_url && activity.actor_detail?.avatar_url !== "" ? (
|
||||
<img
|
||||
src={getFileURL(activity.actor_detail?.avatar_url)}
|
||||
alt={activity.actor_detail?.display_name}
|
||||
className="rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid h-6 w-6 place-items-center rounded border-2 bg-gray-700 text-xs text-white">
|
||||
{activity.actor_detail?.display_name?.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="-mt-1 w-4/5 break-words">
|
||||
<p className="inline text-sm text-custom-text-200">
|
||||
<span className="font-medium text-custom-text-100">
|
||||
{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-xs text-custom-text-200 whitespace-nowrap ">
|
||||
{calculateTimeAgo(activity.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ProfileEmptyState
|
||||
title={t("no_data_yet")}
|
||||
description={t("profile.stats.recent_activity.empty")}
|
||||
image={recentActivityEmptyState}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { BarChart } from "@plane/propel/charts/bar-chart";
|
||||
import type { IUserProfileData } from "@plane/types";
|
||||
import { Loader, Card } from "@plane/ui";
|
||||
import { capitalizeFirstLetter } from "@plane/utils";
|
||||
// components
|
||||
import { ProfileEmptyState } from "@/components/ui/profile-empty-state";
|
||||
// assets
|
||||
import emptyBarGraph from "@/public/empty-state/empty_bar_graph.svg";
|
||||
|
||||
type Props = {
|
||||
userProfile: IUserProfileData | undefined;
|
||||
};
|
||||
|
||||
const priorityColors = {
|
||||
urgent: "#991b1b",
|
||||
high: "#ef4444",
|
||||
medium: "#f59e0b",
|
||||
low: "#16a34a",
|
||||
none: "#e5e5e5",
|
||||
};
|
||||
|
||||
export const ProfilePriorityDistribution: React.FC<Props> = ({ userProfile }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-lg font-medium">{t("profile.stats.priority_distribution.title")}</h3>
|
||||
{userProfile ? (
|
||||
<Card>
|
||||
{userProfile.priority_distribution.length > 0 ? (
|
||||
<BarChart
|
||||
className="w-full h-[300px]"
|
||||
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("profile.stats.priority_distribution.priority"),
|
||||
}}
|
||||
yAxis={{
|
||||
key: "count",
|
||||
label: "",
|
||||
}}
|
||||
barSize={20}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-grow p-7">
|
||||
<ProfileEmptyState
|
||||
title={t("no_data_yet")}
|
||||
description={t("profile.stats.priority_distribution.empty")}
|
||||
image={emptyBarGraph}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
// plane imports
|
||||
import { STATE_GROUPS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PieChart } from "@plane/propel/charts/pie-chart";
|
||||
import type { IUserProfileData, IUserStateDistribution } from "@plane/types";
|
||||
import { Card } from "@plane/ui";
|
||||
import { capitalizeFirstLetter } from "@plane/utils";
|
||||
// components
|
||||
import { ProfileEmptyState } from "@/components/ui/profile-empty-state";
|
||||
// assets
|
||||
import stateGraph from "@/public/empty-state/state_graph.svg";
|
||||
|
||||
type Props = {
|
||||
stateDistribution: IUserStateDistribution[];
|
||||
userProfile: IUserProfileData | undefined;
|
||||
};
|
||||
|
||||
export const ProfileStateDistribution: React.FC<Props> = ({ stateDistribution, userProfile }) => {
|
||||
const { t } = useTranslation();
|
||||
if (!userProfile) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-lg font-medium">{t("profile.stats.state_distribution.title")}</h3>
|
||||
<Card className="h-full">
|
||||
{userProfile.state_distribution.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-x-6 md:grid-cols-2 w-full h-[300px]">
|
||||
<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-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: STATE_GROUPS[group.state_group]?.color ?? "rgb(var(--color-primary-100))",
|
||||
}}
|
||||
/>
|
||||
<div className="whitespace-nowrap">{STATE_GROUPS[group.state_group].label}</div>
|
||||
</div>
|
||||
<div>{group.state_count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ProfileEmptyState
|
||||
title={t("no_data_yet")}
|
||||
description={t("profile.stats.state_distribution.empty")}
|
||||
image={stateGraph}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
72
apps/web/core/components/profile/overview/stats.tsx
Normal file
72
apps/web/core/components/profile/overview/stats.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
// ui
|
||||
import { UserCircle2 } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CreateIcon, LayerStackIcon } from "@plane/propel/icons";
|
||||
import type { IUserProfileData } from "@plane/types";
|
||||
import { Loader, Card, ECardSpacing, ECardDirection } from "@plane/ui";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
userProfile: IUserProfileData | undefined;
|
||||
};
|
||||
|
||||
export const ProfileStats: React.FC<Props> = ({ userProfile }) => {
|
||||
const { workspaceSlug, userId } = useParams();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const overviewCards = [
|
||||
{
|
||||
icon: CreateIcon,
|
||||
route: "created",
|
||||
i18n_title: "profile.stats.created",
|
||||
value: userProfile?.created_issues ?? "...",
|
||||
},
|
||||
{
|
||||
icon: UserCircle2,
|
||||
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-lg 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 bg-custom-background-90">
|
||||
<card.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-custom-text-400">{t(card.i18n_title)}</p>
|
||||
<p className="text-xl 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>
|
||||
);
|
||||
};
|
||||
47
apps/web/core/components/profile/overview/workload.tsx
Normal file
47
apps/web/core/components/profile/overview/workload.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// 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 const ProfileWorkload: React.FC<Props> = ({ stateDistribution }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg 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="h-3 w-3 rounded-sm my-2"
|
||||
style={{
|
||||
backgroundColor: STATE_GROUPS[group.state_group].color,
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-1 flex-col">
|
||||
<span className="text-sm text-custom-text-400">
|
||||
{group.state_group === "unstarted"
|
||||
? "Not started"
|
||||
: group.state_group === "started"
|
||||
? "Working on"
|
||||
: STATE_GROUPS[group.state_group].label}
|
||||
</span>
|
||||
<p className="text-xl font-semibold">{group.state_count}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants";
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { CustomSelect } from "@plane/ui";
|
||||
import { TimezoneSelect } from "@/components/global";
|
||||
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
|
||||
import { useUser, useUserProfile } from "@/hooks/store/user";
|
||||
|
||||
export const LanguageTimezone = observer(() => {
|
||||
// store hooks
|
||||
const {
|
||||
data: user,
|
||||
updateCurrentUser,
|
||||
userProfile: { data: profile },
|
||||
} = useUser();
|
||||
const { updateUserProfile } = useUserProfile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleTimezoneChange = (value: string) => {
|
||||
updateCurrentUser({ user_timezone: value })
|
||||
.then(() => {
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.TIMEZONE_DROPDOWN,
|
||||
},
|
||||
event: {
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.timezone_updated,
|
||||
payload: {
|
||||
timezone: value,
|
||||
},
|
||||
state: "SUCCESS",
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
title: "Success!",
|
||||
message: "Timezone updated successfully",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.TIMEZONE_DROPDOWN,
|
||||
},
|
||||
event: {
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.timezone_updated,
|
||||
state: "ERROR",
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
title: "Error!",
|
||||
message: "Failed to update timezone",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
});
|
||||
};
|
||||
const handleLanguageChange = (value: string) => {
|
||||
updateUserProfile({ language: value })
|
||||
.then(() => {
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.LANGUAGE_DROPDOWN,
|
||||
},
|
||||
event: {
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.language_updated,
|
||||
payload: {
|
||||
language: value,
|
||||
},
|
||||
state: "SUCCESS",
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
title: "Success!",
|
||||
message: "Language updated successfully",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.LANGUAGE_DROPDOWN,
|
||||
},
|
||||
event: {
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.language_updated,
|
||||
state: "ERROR",
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
title: "Error!",
|
||||
message: "Failed to update language",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getLanguageLabel = (value: string) => {
|
||||
const selectedLanguage = SUPPORTED_LANGUAGES.find((l) => l.value === value);
|
||||
if (!selectedLanguage) return value;
|
||||
return selectedLanguage.label;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex flex-col gap-x-6 gap-y-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-4 sm:gap-16 w-full justify-between">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-base font-medium text-custom-text-100"> {t("timezone")} </h4>
|
||||
<p className="text-sm text-custom-text-200">{t("timezone_setting")}</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6 my-auto">
|
||||
<TimezoneSelect value={user?.user_timezone || "Asia/Kolkata"} onChange={handleTimezoneChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-4 sm:gap-16 w-full justify-between">
|
||||
<div className="col-span-12 sm:col-span-6">
|
||||
<h4 className="text-base font-medium text-custom-text-100"> {t("language")} </h4>
|
||||
<p className="text-sm text-custom-text-200">{t("language_setting")}</p>
|
||||
</div>
|
||||
<div className="col-span-12 sm:col-span-6 my-auto">
|
||||
<CustomSelect
|
||||
value={profile?.language}
|
||||
label={profile?.language ? getLanguageLabel(profile?.language) : "Select a language"}
|
||||
onChange={handleLanguageChange}
|
||||
buttonClassName={"border-none"}
|
||||
className="rounded-md border !border-custom-border-200"
|
||||
input
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
81
apps/web/core/components/profile/profile-issues-filter.tsx
Normal file
81
apps/web/core/components/profile/profile-issues-filter.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
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(() => {
|
||||
// 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>
|
||||
);
|
||||
});
|
||||
79
apps/web/core/components/profile/profile-issues.tsx
Normal file
79
apps/web/core/components/profile/profile-issues.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
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((props: Props) => {
|
||||
const { type } = props;
|
||||
const { workspaceSlug, userId } = useParams() as {
|
||||
workspaceSlug: string;
|
||||
userId: string;
|
||||
};
|
||||
// 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 flex-col h-full w-full">
|
||||
{profileWorkItemsFilter && <WorkItemFiltersRow filter={profileWorkItemsFilter} />}
|
||||
<div className="-z-1 relative h-full w-full overflow-auto">
|
||||
{activeLayout === "list" ? (
|
||||
<ProfileIssuesListLayout />
|
||||
) : activeLayout === "kanban" ? (
|
||||
<ProfileIssuesKanBanLayout />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* peek overview */}
|
||||
<IssuePeekOverview />
|
||||
</>
|
||||
)}
|
||||
</WorkspaceLevelWorkItemFiltersHOC>
|
||||
</IssuesStoreContext.Provider>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const ProfileSettingContentHeader: FC<Props> = (props) => {
|
||||
const { title, description } = props;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pb-4 border-b border-custom-border-100 w-full">
|
||||
<div className="text-xl font-medium text-custom-text-100">{title}</div>
|
||||
{description && <div className="text-sm font-normal text-custom-text-300">{description}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
import { SidebarHamburgerToggle } from "@/components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ProfileSettingContentWrapper: FC<Props> = (props) => {
|
||||
const { children, className = "" } = props;
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="block flex-shrink-0 border-b border-custom-border-200 p-4 md:hidden">
|
||||
<SidebarHamburgerToggle />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"vertical-scrollbar scrollbar-md mx-auto h-full w-full flex flex-col px-8 md:px-20 lg:px-36 xl:px-56 py-10 md:py-16",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
292
apps/web/core/components/profile/sidebar.tsx
Normal file
292
apps/web/core/components/profile/sidebar.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { ChevronDown, Pencil } from "lucide-react";
|
||||
// headless ui
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { IUserProfileProjectSegregation } from "@plane/types";
|
||||
// plane ui
|
||||
import { Loader } from "@plane/ui";
|
||||
import { cn, renderFormattedDate, getFileURL } from "@plane/utils";
|
||||
// components
|
||||
import { Logo } from "@/components/common/logo";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
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: FC<TProfileSidebar> = observer((props) => {
|
||||
const { userProjectsData, className = "" } = props;
|
||||
// refs
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// router
|
||||
const { userId, workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { profileSidebarCollapsed, toggleProfileSidebar } = useAppTheme();
|
||||
const { getProjectById } = useProject();
|
||||
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 scrollbar-md fixed z-[5] h-full w-full flex-shrink-0 overflow-hidden overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 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 right-3.5 top-3.5 grid h-5 w-5 place-items-center rounded bg-white">
|
||||
<Link href={`/${workspaceSlug}/settings/account`}>
|
||||
<span className="grid place-items-center text-black">
|
||||
<Pencil className="h-3 w-3" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={
|
||||
userData?.cover_image_url
|
||||
? getFileURL(userData?.cover_image_url)
|
||||
: "/users/user-profile-cover-default-img.png"
|
||||
}
|
||||
alt={userData?.display_name}
|
||||
className="h-[110px] w-full object-cover"
|
||||
/>
|
||||
<div className="absolute -bottom-[26px] left-5 h-[52px] w-[52px] rounded">
|
||||
{userData?.avatar_url && userData?.avatar_url !== "" ? (
|
||||
<img
|
||||
src={getFileURL(userData?.avatar_url)}
|
||||
alt={userData?.display_name}
|
||||
className="h-full w-full rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[52px] w-[52px] items-center justify-center rounded bg-[#028375] capitalize text-white">
|
||||
{userData?.first_name?.[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5">
|
||||
<div className="mt-[38px]">
|
||||
<h4 className="text-lg font-semibold">
|
||||
{userData?.first_name} {userData?.last_name}
|
||||
</h4>
|
||||
<h6 className="text-sm text-custom-text-200">({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-sm">
|
||||
<div className="w-2/5 flex-shrink-0 text-custom-text-200">{t(detail.i18n_label)}</div>
|
||||
<div className="w-3/5 break-words font-medium">{detail.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-9 divide-y divide-custom-border-100">
|
||||
{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 break-words text-sm font-medium">{projectDetails.name}</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{project.assigned_issues > 0 && (
|
||||
<Tooltip tooltipContent="Completion percentage" position="left" isMobile={isMobile}>
|
||||
<div
|
||||
className={`rounded px-1 py-0.5 text-xs font-medium ${
|
||||
completedIssuePercentage <= 35
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: completedIssuePercentage <= 70
|
||||
? "bg-yellow-500/10 text-yellow-500"
|
||||
: "bg-green-500/10 text-green-500"
|
||||
}`}
|
||||
>
|
||||
{completedIssuePercentage}%
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
<Transition
|
||||
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"
|
||||
style={{
|
||||
backgroundColor: "#203b80",
|
||||
width: `${(project.created_issues / totalIssues) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-1 rounded"
|
||||
style={{
|
||||
backgroundColor: "#3f76ff",
|
||||
width: `${(project.assigned_issues / totalIssues) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-1 rounded"
|
||||
style={{
|
||||
backgroundColor: "#f59e0b",
|
||||
width: `${(project.pending_issues / totalIssues) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="h-1 rounded"
|
||||
style={{
|
||||
backgroundColor: "#16a34a",
|
||||
width: `${(project.completed_issues / totalIssues) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-7 space-y-5 text-sm text-custom-text-200">
|
||||
<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-sm 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-sm 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-sm 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-sm 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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import {
|
||||
PROFILE_SETTINGS_TRACKER_ELEMENTS,
|
||||
PROFILE_SETTINGS_TRACKER_EVENTS,
|
||||
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";
|
||||
// hooks
|
||||
import { captureElementAndEvent } from "@/helpers/event-tracker.helper";
|
||||
import { useUserProfile } from "@/hooks/store/user";
|
||||
import { PreferencesSection } from "../preferences/section";
|
||||
|
||||
const getStartOfWeekLabel = (startOfWeek: EStartOfTheWeek) =>
|
||||
START_OF_THE_WEEK_OPTIONS.find((option) => option.value === startOfWeek)?.label;
|
||||
|
||||
export const StartOfWeekPreference = observer((props: { option: { title: string; description: string } }) => {
|
||||
// hooks
|
||||
const { data: userProfile, updateUserProfile } = useUserProfile();
|
||||
|
||||
return (
|
||||
<PreferencesSection
|
||||
title={props.option.title}
|
||||
description={props.option.description}
|
||||
control={
|
||||
<div className="">
|
||||
<CustomSelect
|
||||
value={userProfile.start_of_the_week}
|
||||
label={getStartOfWeekLabel(userProfile.start_of_the_week)}
|
||||
onChange={(val: number) => {
|
||||
updateUserProfile({ start_of_the_week: val })
|
||||
.then(() => {
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.FIRST_DAY_OF_WEEK_DROPDOWN,
|
||||
},
|
||||
event: {
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.first_day_updated,
|
||||
payload: {
|
||||
start_of_the_week: val,
|
||||
},
|
||||
state: "SUCCESS",
|
||||
},
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success",
|
||||
message: "First day of the week updated successfully",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.FIRST_DAY_OF_WEEK_DROPDOWN,
|
||||
},
|
||||
event: {
|
||||
eventName: PROFILE_SETTINGS_TRACKER_EVENTS.first_day_updated,
|
||||
state: "ERROR",
|
||||
},
|
||||
});
|
||||
setToast({ type: TOAST_TYPE.ERROR, title: "Update failed", message: "Please try again later." });
|
||||
});
|
||||
}}
|
||||
input
|
||||
maxHeight="lg"
|
||||
>
|
||||
<>
|
||||
{START_OF_THE_WEEK_OPTIONS.map((day) => (
|
||||
<CustomSelect.Option key={day.value} value={day.value}>
|
||||
{day.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</>
|
||||
</CustomSelect>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
27
apps/web/core/components/profile/time.tsx
Normal file
27
apps/web/core/components/profile/time.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
// hooks
|
||||
import { useCurrentTime } from "@/hooks/use-current-time";
|
||||
|
||||
type Props = {
|
||||
timeZone: string | undefined;
|
||||
};
|
||||
|
||||
export const ProfileSidebarTime: React.FC<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-custom-text-200">{timeZone}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user