feat: user profile analytics, views and filters (#1698)

* feat: user profile overview

* chore: profile sidebar designed

* feat: user issues filters and view options

* refactor: filters

* refactor: mutation logic

* fix: percentage calculation logic and sidebar shadow
This commit is contained in:
Aaryan Khandelwal 2023-07-28 13:39:42 +05:30 committed by GitHub
parent 8930840a76
commit 10f145f85c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 2396 additions and 427 deletions

View File

@ -46,16 +46,7 @@ import { copyTextToClipboard } from "helpers/string.helper";
// types
import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types";
// fetch-keys
import {
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
USER_ISSUES,
VIEW_ISSUES,
} from "constants/fetch-keys";
import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys";
type Props = {
type?: string;
@ -99,10 +90,10 @@ export const SingleBoardIssue: React.FC<Props> = ({
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const { groupByProperty: selectedGroup, orderBy, params, properties } = viewProps;
const { groupByProperty: selectedGroup, orderBy, properties, mutateIssues } = viewProps;
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { setToastAlert } = useToast();
@ -110,16 +101,6 @@ export const SingleBoardIssue: React.FC<Props> = ({
(formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug || !issue) return;
const fetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: viewId
? VIEW_ISSUES(viewId.toString(), params)
: router.pathname.includes("my-issues")
? USER_ISSUES(workspaceSlug.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(issue.project.toString(), params);
if (issue.parent) {
mutate<ISubIssueResponse>(
SUB_ISSUES(issue.parent.toString()),
@ -142,13 +123,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
false
);
} else {
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
fetchKey,
mutateIssues(
(prevData) =>
handleIssuesMutation(
formData,
@ -165,7 +140,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
issuesService
.patchIssue(workspaceSlug as string, issue.project, issue.id, formData, user)
.then(() => {
mutate(fetchKey);
mutateIssues();
if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
@ -175,13 +150,11 @@ export const SingleBoardIssue: React.FC<Props> = ({
workspaceSlug,
cycleId,
moduleId,
viewId,
groupTitle,
index,
selectedGroup,
mutateIssues,
orderBy,
params,
router,
user,
]
);

View File

@ -79,6 +79,7 @@ export const IssuesView: React.FC<Props> = ({
const {
groupedByIssues,
mutateIssues,
issueView,
groupByProperty: selectedGroup,
orderBy,
@ -525,6 +526,7 @@ export const IssuesView: React.FC<Props> = ({
groupedIssues: groupedByIssues,
isEmpty,
issueView,
mutateIssues,
orderBy,
params,
properties,

View File

@ -35,25 +35,9 @@ import { LayerDiagonalIcon } from "components/icons";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import { handleIssuesMutation } from "constants/issue";
// types
import {
ICurrentUserResponse,
IIssue,
IIssueViewProps,
ISubIssueResponse,
Properties,
UserAuth,
} from "types";
import { ICurrentUserResponse, IIssue, IIssueViewProps, ISubIssueResponse, UserAuth } from "types";
// fetch-keys
import {
CYCLE_DETAILS,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_DETAILS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
SUB_ISSUES,
USER_ISSUES,
VIEW_ISSUES,
} from "constants/fetch-keys";
import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys";
type Props = {
type?: string;
@ -89,27 +73,17 @@ export const SingleListIssue: React.FC<Props> = ({
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const isArchivedIssues = router.pathname.includes("archived-issues");
const { setToastAlert } = useToast();
const { groupByProperty: selectedGroup, orderBy, params, properties } = viewProps;
const { groupByProperty: selectedGroup, orderBy, properties, mutateIssues } = viewProps;
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>, issue: IIssue) => {
if (!workspaceSlug || !issue) return;
const fetchKey = cycleId
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
: moduleId
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: viewId
? VIEW_ISSUES(viewId.toString(), params)
: router.pathname.includes("my-issues")
? USER_ISSUES(workspaceSlug.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(issue.project.toString(), params);
if (issue.parent) {
mutate<ISubIssueResponse>(
SUB_ISSUES(issue.parent.toString()),
@ -132,13 +106,7 @@ export const SingleListIssue: React.FC<Props> = ({
false
);
} else {
mutate<
| {
[key: string]: IIssue[];
}
| IIssue[]
>(
fetchKey,
mutateIssues(
(prevData) =>
handleIssuesMutation(
formData,
@ -155,7 +123,7 @@ export const SingleListIssue: React.FC<Props> = ({
issuesService
.patchIssue(workspaceSlug as string, issue.project, issue.id, formData, user)
.then(() => {
mutate(fetchKey);
mutateIssues();
if (cycleId) mutate(CYCLE_DETAILS(cycleId as string));
if (moduleId) mutate(MODULE_DETAILS(moduleId as string));
@ -165,13 +133,11 @@ export const SingleListIssue: React.FC<Props> = ({
workspaceSlug,
cycleId,
moduleId,
viewId,
groupTitle,
index,
selectedGroup,
mutateIssues,
orderBy,
params,
router,
user,
]
);

View File

@ -10,313 +10,14 @@ import issuesService from "services/issues.service";
import { CommentCard } from "components/issues/comment";
// ui
import { Icon, Loader } from "components/ui";
// icons
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon } from "components/icons";
// helpers
import { renderShortDateWithYearFormat, timeAgo } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper";
import { timeAgo } from "helpers/date-time.helper";
import { activityDetails } from "helpers/activity.helper";
// types
import { ICurrentUserResponse, IIssueActivity, IIssueComment } from "types";
import { ICurrentUserResponse, IIssueComment } from "types";
// fetch-keys
import { PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
const activityDetails: {
[key: string]: {
message: (activity: IIssueActivity) => React.ReactNode;
icon: React.ReactNode;
};
} = {
assignees: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
added a new assignee{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the assignee{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <Icon iconName="group" className="!text-sm" aria-hidden="true" />,
},
archived_at: {
message: (activity) => {
if (activity.new_value === "restore") return "restored the issue.";
else return "archived the issue.";
},
icon: <Icon iconName="archive" className="!text-sm" aria-hidden="true" />,
},
attachment: {
message: (activity) => {
if (activity.verb === "created")
return (
<>
uploaded a new{" "}
<a
href={`${activity.new_value}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
attachment
<Icon iconName="launch" className="!text-xs" />
</a>
</>
);
else return "removed an attachment.";
},
icon: <Icon iconName="attach_file" className="!text-sm" aria-hidden="true" />,
},
blocking: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked this issue is blocking issue{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the blocking issue{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
},
blocks: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked this issue is being blocked by{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed this issue being blocked by issue{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
},
cycles: {
message: (activity) => {
if (activity.verb === "created")
return (
<>
added this issue to the cycle{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else if (activity.verb === "updated")
return (
<>
set the cycle to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the issue from the cycle{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <Icon iconName="contrast" className="!text-sm" aria-hidden="true" />,
},
description: {
message: (activity) => "updated the description.",
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
},
estimate_point: {
message: (activity) => {
if (!activity.new_value) return "removed the estimate point.";
else
return (
<>
set the estimate point to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
},
icon: <Icon iconName="change_history" className="!text-sm" aria-hidden="true" />,
},
labels: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
added a new label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
</span>
</>
);
else
return (
<>
removed the label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
</span>
</>
);
},
icon: <Icon iconName="sell" className="!text-sm" aria-hidden="true" />,
},
link: {
message: (activity) => {
if (activity.verb === "created")
return (
<>
added this{" "}
<a
href={`${activity.new_value}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
link
<Icon iconName="launch" className="!text-xs" />
</a>{" "}
to the issue.
</>
);
else
return (
<>
removed this{" "}
<a
href={`${activity.old_value}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
link
<Icon iconName="launch" className="!text-xs" />
</a>{" "}
from the issue.
</>
);
},
icon: <Icon iconName="link" className="!text-sm" aria-hidden="true" />,
},
modules: {
message: (activity) => {
if (activity.verb === "created")
return (
<>
added this issue to the module{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else if (activity.verb === "updated")
return (
<>
set the module to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the issue from the module{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <Icon iconName="dataset" className="!text-sm" aria-hidden="true" />,
},
name: {
message: (activity) => `set the name to ${activity.new_value}.`,
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
},
parent: {
message: (activity) => {
if (!activity.new_value)
return (
<>
removed the parent{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
else
return (
<>
set the parent to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
},
icon: <Icon iconName="supervised_user_circle" className="!text-sm" aria-hidden="true" />,
},
priority: {
message: (activity) => (
<>
set the priority to{" "}
<span className="font-medium text-custom-text-100">
{activity.new_value ? capitalizeFirstLetter(activity.new_value) : "None"}
</span>
.
</>
),
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />,
},
state: {
message: (activity) => (
<>
set the state to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
),
icon: <Squares2X2Icon className="h-3 w-3" aria-hidden="true" />,
},
target_date: {
message: (activity) => {
if (!activity.new_value) return "removed the due date.";
else
return (
<>
set the due date to{" "}
<span className="font-medium text-custom-text-100">
{renderShortDateWithYearFormat(activity.new_value)}
</span>
.
</>
);
},
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
},
};
type Props = {
issueId: string;
user: ICurrentUserResponse | undefined;

View File

@ -25,7 +25,6 @@ import type { IIssue } from "types";
// fetch-keys
import {
PROJECT_ISSUES_DETAILS,
PROJECT_ISSUES_LIST,
USER_ISSUE,
SUB_ISSUES,
PROJECT_ISSUES_LIST_WITH_PARAMS,
@ -40,11 +39,11 @@ import {
import { INBOX_ISSUE_SOURCE } from "constants/inbox";
export interface IssuesModalProps {
isOpen: boolean;
handleClose: () => void;
data?: IIssue | null;
prePopulateData?: Partial<IIssue>;
handleClose: () => void;
isOpen: boolean;
isUpdatingSingleIssue?: boolean;
prePopulateData?: Partial<IIssue>;
fieldsToShow?: (
| "project"
| "name"
@ -58,15 +57,17 @@ export interface IssuesModalProps {
| "parent"
| "all"
)[];
onSubmit?: (data: Partial<IIssue>) => Promise<void>;
}
export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
isOpen,
handleClose,
data,
prePopulateData,
handleClose,
isOpen,
isUpdatingSingleIssue = false,
prePopulateData,
fieldsToShow = ["all"],
onSubmit,
}) => {
// states
const [createMore, setCreateMore] = useState(false);
@ -95,9 +96,14 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
};
useEffect(() => {
if (data && data.project) {
setActiveProject(data.project);
return;
}
if (projects && projects.length > 0 && !activeProject)
setActiveProject(projects?.find((p) => p.id === projectId)?.id ?? projects?.[0].id ?? null);
}, [activeProject, projectId, projects]);
}, [activeProject, data, projectId, projects]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -306,6 +312,8 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = ({
if (!data) await createIssue(payload);
else await updateIssue(payload);
if (onSubmit) await onSubmit(payload);
};
if (!projects || projects.length === 0) return null;

View File

@ -15,12 +15,12 @@ import useUserAuth from "hooks/use-user-auth";
// components
import { AllViews, FiltersList } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import { CreateUpdateViewModal } from "components/views";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types
import { IIssue, IIssueFilterOptions } from "types";
// fetch-keys
import { USER_ISSUES, WORKSPACE_LABELS } from "constants/fetch-keys";
import { orderArrayBy } from "helpers/array.helper";
type Props = {
openIssuesListModal?: () => void;
@ -33,7 +33,6 @@ export const MyIssuesView: React.FC<Props> = ({
}) => {
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [createViewModal, setCreateViewModal] = useState<any>(null);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined);
@ -56,15 +55,15 @@ export const MyIssuesView: React.FC<Props> = ({
const { user } = useUserAuth();
const { groupedIssues, isEmpty, params } = useMyIssues(workspaceSlug?.toString());
const { groupedIssues, mutateMyIssues, isEmpty, params } = useMyIssues(workspaceSlug?.toString());
const { filters, setFilters, issueView, groupBy, orderBy, properties, showEmptyGroups } =
useMyIssuesFilters(workspaceSlug?.toString());
const { data: labels } = useSWR(
workspaceSlug && (filters.labels ?? []).length > 0
workspaceSlug && (filters?.labels ?? []).length > 0
? WORKSPACE_LABELS(workspaceSlug.toString())
: null,
workspaceSlug && (filters.labels ?? []).length > 0
workspaceSlug && (filters?.labels ?? []).length > 0
? () => issuesService.getWorkspaceLabels(workspaceSlug.toString())
: null
);
@ -80,7 +79,6 @@ export const MyIssuesView: React.FC<Props> = ({
const handleOnDragEnd = useCallback(
async (result: DropResult) => {
setTrashBox(false);
console.log(result);
if (!result.destination || !workspaceSlug || !groupedIssues || groupBy !== "priority") return;
@ -212,23 +210,23 @@ export const MyIssuesView: React.FC<Props> = ({
return (
<>
<CreateUpdateViewModal
isOpen={createViewModal !== null}
handleClose={() => setCreateViewModal(null)}
preLoadedData={createViewModal}
user={user}
/>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
onSubmit={async () => {
mutateMyIssues();
}}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
handleClose={() => setEditIssueModal(false)}
data={issueToEdit}
onSubmit={async () => {
mutateMyIssues();
}}
/>
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
@ -275,6 +273,7 @@ export const MyIssuesView: React.FC<Props> = ({
groupedIssues,
isEmpty,
issueView,
mutateIssues: mutateMyIssues,
orderBy,
params,
properties,

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { useState } from "react";
import { useRouter } from "next/router";
@ -15,6 +15,7 @@ import { UserGroupIcon } from "@heroicons/react/24/outline";
import { ICurrentUserResponse, IIssue } from "types";
// fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys";
import useProjectMembers from "hooks/use-project-members";
type Props = {
issue: IIssue;
@ -37,15 +38,12 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
isNotAllowed,
customButton = false,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const [fetchAssignees, setFetchAssignees] = useState(false);
const { data: members } = useSWR(
projectId ? PROJECT_MEMBERS(projectId as string) : null,
workspaceSlug && projectId
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
: null
);
const router = useRouter();
const { workspaceSlug } = router.query;
const { members } = useProjectMembers(workspaceSlug?.toString(), issue.project, fetchAssignees);
const options = members?.map((member) => ({
value: member.member.id,
@ -129,6 +127,7 @@ export const ViewAssigneeSelect: React.FC<Props> = ({
noChevron
position={position}
disabled={isNotAllowed}
onOpen={() => setFetchAssignees(true)}
selfPositioned={selfPositioned}
width="w-full min-w-[12rem]"
/>

View File

@ -0,0 +1,5 @@
export * from "./overview";
export * from "./navbar";
export * from "./profile-issues-view-options";
export * from "./profile-issues-view";
export * from "./sidebar";

View File

@ -0,0 +1,54 @@
import { useRouter } from "next/router";
import Link from "next/link";
// components
import { ProfileIssuesViewOptions } from "components/profile";
const tabsList = [
{
route: "",
label: "Overview",
selected: "/[workspaceSlug]/profile/[userId]",
},
{
route: "assigned",
label: "Assigned",
selected: "/[workspaceSlug]/profile/[userId]/assigned",
},
{
route: "created",
label: "Created",
selected: "/[workspaceSlug]/profile/[userId]/created",
},
{
route: "subscribed",
label: "Subscribed",
selected: "/[workspaceSlug]/profile/[userId]/subscribed",
},
];
export const ProfileNavbar = () => {
const router = useRouter();
const { workspaceSlug, userId } = router.query;
return (
<div className="px-4 sm:px-5 flex items-center justify-between gap-4 border-b border-custom-border-300">
<div className="flex items-center overflow-x-scroll">
{tabsList.map((tab) => (
<Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}>
<a
className={`border-b-2 p-4 text-sm font-medium outline-none whitespace-nowrap ${
router.pathname === tab.selected
? "border-custom-primary-100 text-custom-primary-100"
: "border-transparent"
}`}
>
{tab.label}
</a>
</Link>
))}
</div>
<ProfileIssuesViewOptions />
</div>
);
};

View File

@ -0,0 +1,4 @@
export * from "./priority-distribution";
export * from "./state-distribution";
export * from "./stats";
export * from "./workload";

View File

@ -0,0 +1,75 @@
// ui
import { BarGraph, Loader } from "components/ui";
// helpers
import { capitalizeFirstLetter } from "helpers/string.helper";
// types
import { IUserProfileData } from "types";
type Props = {
userProfile: IUserProfileData | undefined;
};
export const ProfilePriorityDistribution: React.FC<Props> = ({ userProfile }) => (
<div className="space-y-2">
<h3 className="text-lg font-medium">Issues by Priority</h3>
{userProfile ? (
<div className="border border-custom-border-100 rounded">
<BarGraph
data={userProfile.priority_distribution.map((priority) => ({
priority: capitalizeFirstLetter(priority.priority ?? "None"),
value: priority.priority_count,
}))}
height="300px"
indexBy="priority"
keys={["value"]}
borderRadius={4}
padding={0.7}
customYAxisTickValues={userProfile.priority_distribution.map((p) => p.priority_count)}
tooltip={(datum) => (
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
<span
className="h-3 w-3 rounded"
style={{
backgroundColor: datum.color,
}}
/>
<span className="font-medium text-custom-text-200">{datum.data.priority}:</span>
<span>{datum.value}</span>
</div>
)}
colors={(datum) => {
if (datum.data.priority === "Urgent") return "#991b1b";
else if (datum.data.priority === "High") return "#ef4444";
else if (datum.data.priority === "Medium") return "#f59e0b";
else if (datum.data.priority === "Low") return "#16a34a";
else return "#e5e5e5";
}}
theme={{
axis: {
domain: {
line: {
stroke: "transparent",
},
},
},
grid: {
line: {
stroke: "transparent",
},
},
}}
/>
</div>
) : (
<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,81 @@
// ui
import { PieGraph } from "components/ui";
// types
import { IUserProfileData, IUserStateDistribution } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
type Props = {
stateDistribution: IUserStateDistribution[];
userProfile: IUserProfileData | undefined;
};
export const ProfileStateDistribution: React.FC<Props> = ({ stateDistribution, userProfile }) => {
if (!userProfile) return null;
return (
<div className="space-y-2">
<h3 className="text-lg font-medium">Issues by State</h3>
<div className="border border-custom-border-100 rounded p-7">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<div>
<PieGraph
data={
userProfile.state_distribution.map((group) => ({
id: group.state_group,
label: group.state_group,
value: group.state_count,
color: STATE_GROUP_COLORS[group.state_group],
})) ?? []
}
height="250px"
innerRadius={0.6}
cornerRadius={5}
padAngle={2}
enableArcLabels
arcLabelsTextColor="#000000"
enableArcLinkLabels={false}
activeInnerRadiusOffset={5}
colors={(datum) => datum.data.color}
tooltip={(datum) => (
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 p-2 text-xs">
<span className="text-custom-text-200 capitalize">
{datum.datum.label} issues:
</span>{" "}
{datum.datum.value}
</div>
)}
margin={{
top: 32,
right: 0,
bottom: 32,
left: 0,
}}
/>
</div>
<div className="flex items-center">
<div className="space-y-4 w-full">
{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_GROUP_COLORS[group.state_group],
}}
/>
<div className="capitalize whitespace-nowrap">{group.state_group}</div>
</div>
<div>{group.state_count}</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,66 @@
import { useRouter } from "next/router";
import Link from "next/link";
// ui
import { Icon, Loader } from "components/ui";
// types
import { IUserProfileData } from "types";
type Props = {
userProfile: IUserProfileData | undefined;
};
export const ProfileStats: React.FC<Props> = ({ userProfile }) => {
const router = useRouter();
const { workspaceSlug, userId } = router.query;
const overviewCards = [
{
icon: "new_window",
route: "created",
title: "Issues created",
value: userProfile?.created_issues ?? "...",
},
{
icon: "account_circle",
route: "assigned",
title: "Issues assigned",
value: userProfile?.assigned_issues ?? "...",
},
{
icon: "subscriptions",
route: "subscribed",
title: "Issues subscribed",
value: userProfile?.subscribed_issues ?? "...",
},
];
return (
<div className="space-y-2">
<h3 className="text-lg font-medium">Overview</h3>
{userProfile ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{overviewCards.map((card) => (
<Link key={card.route} href={`/${workspaceSlug}/profile/${userId}/${card.route}`}>
<a className="flex items-center gap-3 p-4 rounded border border-custom-border-100 whitespace-nowrap">
<div className="h-11 w-11 bg-custom-background-90 rounded grid place-items-center">
<Icon iconName={card.icon} className="!text-xl" />
</div>
<div className="space-y-1">
<p className="text-custom-text-400 text-sm">{card.title}</p>
<p className="text-xl font-semibold">{card.value}</p>
</div>
</a>
</Link>
))}
</div>
) : (
<Loader className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Loader.Item height="80px" />
<Loader.Item height="80px" />
<Loader.Item height="80px" />
</Loader>
)}
</div>
);
};

View File

@ -0,0 +1,32 @@
// types
import { IUserStateDistribution } from "types";
// constants
import { STATE_GROUP_COLORS } from "constants/state";
type Props = {
stateDistribution: IUserStateDistribution[];
};
export const ProfileWorkload: React.FC<Props> = ({ stateDistribution }) => (
<div className="space-y-2">
<h3 className="text-lg font-medium">Workload</h3>
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-5 gap-4 justify-stretch">
{stateDistribution.map((group) => (
<div key={group.state_group}>
<a className="flex gap-2 p-4 rounded border border-custom-border-100 whitespace-nowrap">
<div
className="h-3 w-3 rounded-sm"
style={{
backgroundColor: STATE_GROUP_COLORS[group.state_group],
}}
/>
<div className="space-y-1 -mt-1">
<p className="text-custom-text-400 text-sm capitalize">{group.state_group}</p>
<p className="text-xl font-semibold">{group.state_count}</p>
</div>
</a>
</div>
))}
</div>
</div>
);

View File

@ -0,0 +1,298 @@
import React from "react";
import { useRouter } from "next/router";
// headless ui
import { Popover, Transition } from "@headlessui/react";
// hooks
import useProfileIssues from "hooks/use-profile-issues";
import useEstimateOption from "hooks/use-estimate-option";
// components
import { MyIssuesSelectFilters } from "components/issues";
// ui
import { CustomMenu, ToggleSwitch, Tooltip } from "components/ui";
// icons
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import { FormatListBulletedOutlined, GridViewOutlined } from "@mui/icons-material";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
import { checkIfArraysHaveSameElements } from "helpers/array.helper";
// types
import { Properties, TIssueViewOptions } from "types";
// constants
import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue";
const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [
{
type: "list",
Icon: FormatListBulletedOutlined,
},
{
type: "kanban",
Icon: GridViewOutlined,
},
];
export const ProfileIssuesViewOptions: React.FC = () => {
const router = useRouter();
const { workspaceSlug, userId } = router.query;
const {
issueView,
setIssueView,
groupByProperty,
setGroupByProperty,
orderBy,
setOrderBy,
showEmptyGroups,
setShowEmptyGroups,
filters,
properties,
setProperties,
setFilters,
} = useProfileIssues(workspaceSlug?.toString(), userId?.toString());
const { isEstimateActive } = useEstimateOption();
if (
!router.pathname.includes("assigned") &&
!router.pathname.includes("created") &&
!router.pathname.includes("subscribed")
)
return null;
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-x-1">
{issueViewOptions.map((option) => (
<Tooltip
key={option.type}
tooltipContent={
<span className="capitalize">{replaceUnderscoreIfSnakeCase(option.type)} View</span>
}
position="bottom"
>
<button
type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 duration-300 ${
issueView === option.type
? "bg-custom-sidebar-background-80"
: "text-custom-sidebar-text-200"
}`}
onClick={() => setIssueView(option.type)}
>
<option.Icon
sx={{
fontSize: 16,
}}
className={option.type === "gantt_chart" ? "rotate-90" : ""}
/>
</button>
</Tooltip>
))}
</div>
<MyIssuesSelectFilters
filters={filters}
onSelect={(option) => {
const key = option.key as keyof typeof filters;
if (key === "target_date") {
const valueExists = checkIfArraysHaveSameElements(
filters?.target_date ?? [],
option.value
);
setFilters({
target_date: valueExists ? null : option.value,
});
} else {
const valueExists = filters[key]?.includes(option.value);
if (valueExists)
setFilters({
[option.key]: ((filters[key] ?? []) as any[])?.filter(
(val) => val !== option.value
),
});
else
setFilters({
[option.key]: [...((filters[key] ?? []) as any[]), option.value],
});
}
}}
direction="left"
height="rg"
/>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center gap-2 rounded-md border border-custom-sidebar-border-200 bg-transparent px-3 py-1.5 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
open
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
: "text-custom-sidebar-text-200"
}`}
>
View
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg">
<div className="relative divide-y-2 divide-custom-border-200">
<div className="space-y-4 pb-3 text-xs">
{issueView !== "calendar" && issueView !== "spreadsheet" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Group by</h4>
<CustomMenu
label={
groupByProperty === "project"
? "Project"
: GROUP_BY_OPTIONS.find((option) => option.key === groupByProperty)
?.name ?? "Select"
}
>
{GROUP_BY_OPTIONS.map((option) => {
if (issueView === "kanban" && option.key === null) return null;
if (option.key === "state" || option.key === "created_by")
return null;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => setGroupByProperty(option.key)}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Order by</h4>
<CustomMenu
label={
ORDER_BY_OPTIONS.find((option) => option.key === orderBy)?.name ??
"Select"
}
>
{ORDER_BY_OPTIONS.map((option) => {
if (groupByProperty === "priority" && option.key === "priority")
return null;
if (option.key === "sort_order") return null;
return (
<CustomMenu.MenuItem
key={option.key}
onClick={() => {
setOrderBy(option.key);
}}
>
{option.name}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
</>
)}
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Issue type</h4>
<CustomMenu
label={
FILTER_ISSUE_OPTIONS.find((option) => option.key === filters?.type)
?.name ?? "Select"
}
>
{FILTER_ISSUE_OPTIONS.map((option) => (
<CustomMenu.MenuItem
key={option.key}
onClick={() =>
setFilters({
type: option.key,
})
}
>
{option.name}
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
{issueView !== "calendar" && issueView !== "spreadsheet" && (
<>
<div className="flex items-center justify-between">
<h4 className="text-custom-text-200">Show empty states</h4>
<ToggleSwitch value={showEmptyGroups} onChange={setShowEmptyGroups} />
</div>
{/* <div className="relative flex justify-end gap-x-3">
<button type="button" onClick={() => resetFilterToDefault()}>
Reset to default
</button>
<button
type="button"
className="font-medium text-custom-primary"
onClick={() => setNewFilterDefaultView()}
>
Set as default
</button>
</div> */}
</>
)}
</div>
<div className="space-y-2 py-3">
<h4 className="text-sm text-custom-text-200">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null;
if (
issueView === "spreadsheet" &&
(key === "attachment_count" ||
key === "link" ||
key === "sub_issue_count")
)
return null;
if (
issueView !== "spreadsheet" &&
(key === "created_on" || key === "updated_on")
)
return null;
return (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-custom-primary bg-custom-primary text-white"
: "border-custom-border-200"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
);
};

View File

@ -0,0 +1,273 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// react-beautiful-dnd
import { DropResult } from "react-beautiful-dnd";
// services
import issuesService from "services/issues.service";
// hooks
import useProfileIssues from "hooks/use-profile-issues";
import useUser from "hooks/use-user";
// components
import { AllViews, FiltersList } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
// helpers
import { orderArrayBy } from "helpers/array.helper";
// types
import { IIssue, IIssueFilterOptions } from "types";
// fetch-keys
import { WORKSPACE_LABELS } from "constants/fetch-keys";
export const ProfileIssuesView = () => {
// create issue modal
const [createIssueModal, setCreateIssueModal] = useState(false);
const [preloadedData, setPreloadedData] = useState<
(Partial<IIssue> & { actionType: "createIssue" | "edit" | "delete" }) | undefined
>(undefined);
// update issue modal
const [editIssueModal, setEditIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<
(IIssue & { actionType: "edit" | "delete" }) | undefined
>(undefined);
// delete issue modal
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [issueToDelete, setIssueToDelete] = useState<IIssue | null>(null);
// trash box
const [trashBox, setTrashBox] = useState(false);
const router = useRouter();
const { workspaceSlug, userId } = router.query;
const { user } = useUser();
const {
groupedIssues,
mutateProfileIssues,
issueView,
groupByProperty,
orderBy,
isEmpty,
showEmptyGroups,
filters,
setFilters,
properties,
params,
} = useProfileIssues(workspaceSlug?.toString(), userId?.toString());
const { data: labels } = useSWR(
workspaceSlug && (filters?.labels ?? []).length > 0
? WORKSPACE_LABELS(workspaceSlug.toString())
: null,
workspaceSlug && (filters?.labels ?? []).length > 0
? () => issuesService.getWorkspaceLabels(workspaceSlug.toString())
: null
);
const handleDeleteIssue = useCallback(
(issue: IIssue) => {
setDeleteIssueModal(true);
setIssueToDelete(issue);
},
[setDeleteIssueModal, setIssueToDelete]
);
const handleOnDragEnd = useCallback(
async (result: DropResult) => {
setTrashBox(false);
if (!result.destination || !workspaceSlug || !groupedIssues || groupByProperty !== "priority")
return;
const { source, destination } = result;
if (source.droppableId === destination.droppableId) return;
const draggedItem = groupedIssues[source.droppableId][source.index];
if (!draggedItem) return;
if (destination.droppableId === "trashBox") handleDeleteIssue(draggedItem);
else {
const sourceGroup = source.droppableId;
const destinationGroup = destination.droppableId;
draggedItem[groupByProperty] = destinationGroup;
mutateProfileIssues((prevData) => {
if (!prevData) return prevData;
const sourceGroupArray = [...groupedIssues[sourceGroup]];
const destinationGroupArray = [...groupedIssues[destinationGroup]];
sourceGroupArray.splice(source.index, 1);
destinationGroupArray.splice(destination.index, 0, draggedItem);
return {
...prevData,
[sourceGroup]: orderArrayBy(sourceGroupArray, orderBy),
[destinationGroup]: orderArrayBy(destinationGroupArray, orderBy),
};
}, false);
// patch request
issuesService
.patchIssue(
workspaceSlug as string,
draggedItem.project,
draggedItem.id,
{
priority: draggedItem.priority,
},
user
)
.catch(() => mutateProfileIssues());
}
},
[
groupByProperty,
groupedIssues,
handleDeleteIssue,
mutateProfileIssues,
orderBy,
user,
workspaceSlug,
]
);
const addIssueToGroup = useCallback((groupTitle: string) => {
setCreateIssueModal(true);
return;
}, []);
const addIssueToDate = useCallback(
(date: string) => {
setCreateIssueModal(true);
setPreloadedData({
target_date: date,
actionType: "createIssue",
});
},
[setCreateIssueModal, setPreloadedData]
);
const makeIssueCopy = useCallback(
(issue: IIssue) => {
setCreateIssueModal(true);
setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" });
},
[setCreateIssueModal, setPreloadedData]
);
const handleEditIssue = useCallback(
(issue: IIssue) => {
setEditIssueModal(true);
setIssueToEdit({
...issue,
actionType: "edit",
cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null,
module: issue.issue_module ? issue.issue_module.module : null,
});
},
[setEditIssueModal, setIssueToEdit]
);
const handleIssueAction = useCallback(
(issue: IIssue, action: "copy" | "edit" | "delete") => {
if (action === "copy") makeIssueCopy(issue);
else if (action === "edit") handleEditIssue(issue);
else if (action === "delete") handleDeleteIssue(issue);
},
[makeIssueCopy, handleEditIssue, handleDeleteIssue]
);
const filtersToDisplay = { ...filters, assignees: null, created_by: null, subscriber: null };
const nullFilters = Object.keys(filtersToDisplay).filter(
(key) => filtersToDisplay[key as keyof IIssueFilterOptions] === null
);
const areFiltersApplied =
Object.keys(filtersToDisplay).length > 0 &&
nullFilters.length !== Object.keys(filtersToDisplay).length;
return (
<>
<CreateUpdateIssueModal
isOpen={createIssueModal && preloadedData?.actionType === "createIssue"}
handleClose={() => setCreateIssueModal(false)}
prePopulateData={{
...preloadedData,
}}
onSubmit={async () => {
mutateProfileIssues();
}}
/>
<CreateUpdateIssueModal
isOpen={editIssueModal && issueToEdit?.actionType !== "delete"}
handleClose={() => setEditIssueModal(false)}
data={issueToEdit}
onSubmit={async () => {
mutateProfileIssues();
}}
/>
<DeleteIssueModal
handleClose={() => setDeleteIssueModal(false)}
isOpen={deleteIssueModal}
data={issueToDelete}
user={user}
/>
{areFiltersApplied && (
<>
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
<FiltersList
filters={filtersToDisplay}
setFilters={setFilters}
labels={labels}
members={undefined}
states={undefined}
clearAllFilters={() =>
setFilters({
labels: null,
priority: null,
state_group: null,
target_date: null,
type: null,
})
}
/>
</div>
{<div className="mt-3 border-t border-custom-border-200" />}
</>
)}
<AllViews
addIssueToDate={addIssueToDate}
addIssueToGroup={addIssueToGroup}
disableUserActions={false}
dragDisabled={groupByProperty !== "priority"}
handleOnDragEnd={handleOnDragEnd}
handleIssueAction={handleIssueAction}
openIssuesListModal={null}
removeIssue={null}
trashBox={trashBox}
setTrashBox={setTrashBox}
viewProps={{
groupByProperty,
groupedIssues,
isEmpty,
issueView,
mutateIssues: mutateProfileIssues,
orderBy,
params,
properties,
showEmptyGroups,
}}
/>
</>
);
};

View File

@ -0,0 +1,256 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// next-themes
import { useTheme } from "next-themes";
// headless ui
import { Disclosure, Transition } from "@headlessui/react";
// services
import userService from "services/user.service";
// ui
import { Icon, Loader } from "components/ui";
// helpers
import { renderLongDetailDateFormat } from "helpers/date-time.helper";
import { renderEmoji } from "helpers/emoji.helper";
// fetch-keys
import { USER_PROFILE_PROJECT_SEGREGATION } from "constants/fetch-keys";
export const ProfileSidebar = () => {
const router = useRouter();
const { workspaceSlug, userId } = router.query;
const { theme } = useTheme();
const { data: userProjectsData } = useSWR(
workspaceSlug && userId
? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString())
: null,
workspaceSlug && userId
? () =>
userService.getUserProfileProjectsSegregation(workspaceSlug.toString(), userId.toString())
: null
);
const userDetails = [
{
label: "Username",
value: "",
},
{
label: "Joined on",
value: renderLongDetailDateFormat(userProjectsData?.user_data.date_joined ?? ""),
},
{
label: "Timezone",
value: userProjectsData?.user_data.user_timezone,
},
{
label: "Status",
value: "Online",
},
];
return (
<div
className="flex-shrink-0 h-full w-80 overflow-y-auto"
style={{
boxShadow:
theme === "light"
? "0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12)"
: "0px 0px 4px 0px rgba(0, 0, 0, 0.20), 0px 2px 6px 0px rgba(0, 0, 0, 0.50)",
}}
>
{userProjectsData ? (
<>
<div className="relative h-32">
<img
src={
userProjectsData.user_data.cover_image ??
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80"
}
alt={userProjectsData.user_data.first_name}
className="h-32 w-full object-cover"
/>
<div className="absolute -bottom-[26px] left-5 h-[52px] w-[52px] rounded">
{userProjectsData.user_data.avatar && userProjectsData.user_data.avatar !== "" ? (
<img
src={userProjectsData.user_data.avatar}
alt={userProjectsData.user_data.first_name}
className="rounded"
/>
) : (
<div className="bg-custom-background-90 text-custom-text-100">
{userProjectsData.user_data.first_name[0]}
</div>
)}
</div>
</div>
<div className="px-5">
<div className="mt-[38px]">
<h4 className="text-lg font-semibold">
{userProjectsData.user_data.first_name} {userProjectsData.user_data.last_name}
</h4>
<h6 className="text-custom-text-200 text-sm">{userProjectsData.user_data.email}</h6>
</div>
<div className="mt-6 space-y-5">
{userDetails.map((detail) => (
<div key={detail.label} className="flex items-center gap-4 text-sm">
<div className="text-custom-text-200 w-2/5">{detail.label}</div>
<div className="font-medium">{detail.value}</div>
</div>
))}
</div>
<div className="mt-9 divide-y divide-custom-border-100">
{userProjectsData.project_data.map((project, index) => {
const totalIssues =
project.created_issues +
project.assigned_issues +
project.pending_issues +
project.completed_issues;
const totalAssignedIssues = totalIssues - project.created_issues;
const completedIssuePercentage =
totalAssignedIssues === 0
? 0
: Math.round((project.completed_issues / totalAssignedIssues) * 100);
return (
<Disclosure
key={project.id}
as="div"
className={`${index === 0 ? "pb-3" : "py-3"}`}
>
{({ open }) => (
<div className="w-full">
<Disclosure.Button className="flex items-center justify-between gap-2 w-full">
<div className="flex items-center gap-2 w-3/4">
{project.emoji ? (
<div className="flex-shrink-0 grid h-7 w-7 place-items-center">
{renderEmoji(project.emoji)}
</div>
) : project.icon_prop ? (
<div className="flex-shrink-0 h-7 w-7 grid place-items-center">
{renderEmoji(project.icon_prop)}
</div>
) : (
<div className="flex-shrink-0 grid place-items-center h-7 w-7 rounded bg-custom-background-90 uppercase text-custom-text-100 text-xs">
{project?.name.charAt(0)}
</div>
)}
<div className="text-sm font-medium truncate break-words">
{project.name}
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
<div
className={`px-1 py-0.5 text-xs font-medium rounded ${
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>
<Icon iconName="arrow_drop_down" className="!text-lg" />
</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="pl-9 mt-5">
{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 bg-[#203b80] rounded-sm" />
Created
</div>
<div className="font-medium">{project.created_issues} 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 bg-[#3f76ff] rounded-sm" />
Assigned
</div>
<div className="font-medium">{project.assigned_issues} 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 bg-[#f59e0b] rounded-sm" />
Due
</div>
<div className="font-medium">{project.pending_issues} 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 bg-[#16a34a] rounded-sm" />
Completed
</div>
<div className="font-medium">{project.completed_issues} Issues</div>
</div>
</div>
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
);
})}
</div>
</div>
</>
) : (
<Loader className="px-5 space-y-7">
<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

@ -42,6 +42,7 @@ const restrictedUrls = [
"invitations",
"magic-sign-in",
"onboarding",
"profile",
"reset-password",
"sign-up",
"workspace-member-invitation",

View File

@ -151,11 +151,9 @@ export const STATES_LIST = (projectId: string) => `STATES_LIST_${projectId.toUpp
export const USER_ISSUE = (workspaceSlug: string) => `USER_ISSUE_${workspaceSlug.toUpperCase()}`;
export const USER_ISSUES = (workspaceSlug: string, params: any) => {
if (!params) return `USER_ISSUES_${workspaceSlug.toUpperCase()}`;
const paramsKey = myIssuesParamsToKey(params);
return `USER_ISSUES_${paramsKey}`;
return `USER_ISSUES_${workspaceSlug.toUpperCase()}_${paramsKey}`;
};
export const USER_ACTIVITY = "USER_ACTIVITY";
export const USER_WORKSPACE_DASHBOARD = (workspaceSlug: string) =>
@ -302,9 +300,24 @@ export const getPaginatedNotificationKey = (
})}`;
};
// profile
export const USER_PROFILE_DATA = (workspaceSlug: string, userId: string) =>
`USER_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
export const USER_PROFILE_ACTIVITY = (workspaceSlug: string, userId: string) =>
`USER_WORKSPACE_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
export const USER_PROFILE_PROJECT_SEGREGATION = (workspaceSlug: string, userId: string) =>
`USER_PROFILE_PROJECT_SEGREGATION_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
export const USER_PROFILE_ISSUES = (workspaceSlug: string, userId: string, params: any) => {
const paramsKey = myIssuesParamsToKey(params);
const subscriberKey = params.subscriber ? params.subscriber.toUpperCase() : "NULL";
return `USER_PROFILE_ISSUES_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}_${paramsKey}_${subscriberKey}`;
};
// reactions
export const ISSUE_REACTION_LIST = (workspaceSlug: string, projectId: string, issueId: string) =>
`ISSUE_REACTION_LIST_${workspaceSlug.toUpperCase()}_${projectId.toUpperCase()}_${issueId.toUpperCase()}`;
export const COMMENT_REACTION_LIST = (
workspaceSlug: string,
projectId: string,

View File

@ -1,9 +1,9 @@
export const STATE_GROUP_COLORS: {
[key: string]: string;
} = {
backlog: "#ced4da",
unstarted: "#26b5ce",
started: "#f7ae59",
cancelled: "#d687ff",
completed: "#09a953",
backlog: "#d9d9d9",
unstarted: "#3f76ff",
started: "#f59e0b",
completed: "#16a34a",
cancelled: "#dc2626",
};

View File

@ -0,0 +1,310 @@
import { createContext, useCallback, useReducer } from "react";
// components
import ToastAlert from "components/toast-alert";
// types
import {
IIssueFilterOptions,
TIssueViewOptions,
TIssueGroupByOptions,
TIssueOrderByOptions,
Properties,
} from "types";
export const profileIssuesContext = createContext<ContextType>({} as ContextType);
type IssueViewProps = {
issueView: TIssueViewOptions;
groupByProperty: TIssueGroupByOptions;
orderBy: TIssueOrderByOptions;
showEmptyGroups: boolean;
showSubIssues: boolean;
filters: IIssueFilterOptions & { subscriber: string | null };
properties: Properties;
};
type ReducerActionType = {
type:
| "SET_ISSUE_VIEW"
| "SET_ORDER_BY_PROPERTY"
| "SET_SHOW_EMPTY_STATES"
| "SET_FILTERS"
| "SET_PROPERTIES"
| "SET_GROUP_BY_PROPERTY"
| "RESET_TO_DEFAULT"
| "SET_SHOW_SUB_ISSUES";
payload?: Partial<IssueViewProps>;
};
type ContextType = IssueViewProps & {
setGroupByProperty: (property: TIssueGroupByOptions) => void;
setOrderBy: (property: TIssueOrderByOptions) => void;
setShowEmptyGroups: (property: boolean) => void;
setShowSubIssues: (value: boolean) => void;
setFilters: (filters: Partial<IIssueFilterOptions & { subscriber: string | null }>) => void;
setProperties: (key: keyof Properties) => void;
setIssueView: (property: TIssueViewOptions) => void;
};
type StateType = {
issueView: TIssueViewOptions;
groupByProperty: TIssueGroupByOptions;
orderBy: TIssueOrderByOptions;
showEmptyGroups: boolean;
showSubIssues: boolean;
filters: IIssueFilterOptions & { subscriber: string | null };
properties: Properties;
};
type ReducerFunctionType = (state: StateType, action: ReducerActionType) => StateType;
export const initialState: StateType = {
issueView: "list",
groupByProperty: null,
orderBy: "-created_at",
showEmptyGroups: true,
showSubIssues: true,
filters: {
type: null,
priority: null,
assignees: null,
labels: null,
state: null,
state_group: null,
subscriber: null,
created_by: null,
target_date: null,
},
properties: {
assignee: true,
attachment_count: true,
created_on: true,
due_date: true,
estimate: true,
key: true,
labels: true,
link: true,
priority: true,
state: true,
sub_issue_count: true,
updated_on: true,
},
};
export const reducer: ReducerFunctionType = (state, action) => {
const { type, payload } = action;
switch (type) {
case "SET_ISSUE_VIEW": {
const newState = {
...state,
issueView: payload?.issueView || "list",
};
return {
...state,
...newState,
};
}
case "SET_GROUP_BY_PROPERTY": {
const newState = {
...state,
groupByProperty: payload?.groupByProperty || null,
};
return {
...state,
...newState,
};
}
case "SET_ORDER_BY_PROPERTY": {
const newState = {
...state,
orderBy: payload?.orderBy || "-created_at",
};
return {
...state,
...newState,
};
}
case "SET_SHOW_EMPTY_STATES": {
const newState = {
...state,
showEmptyGroups: payload?.showEmptyGroups || true,
};
return {
...state,
...newState,
};
}
case "SET_SHOW_SUB_ISSUES": {
const newState = {
...state,
showSubIssues: payload?.showSubIssues || true,
};
return {
...state,
...newState,
};
}
case "SET_FILTERS": {
const newState = {
...state,
filters: {
...state.filters,
...payload?.filters,
},
};
return {
...state,
...newState,
};
}
case "SET_PROPERTIES": {
const newState = {
...state,
properties: {
...state.properties,
...payload?.properties,
},
};
return {
...state,
...newState,
};
}
default: {
return state;
}
}
};
export const ProfileIssuesContextProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [state, dispatch] = useReducer(reducer, initialState);
const setIssueView = useCallback((property: TIssueViewOptions) => {
dispatch({
type: "SET_ISSUE_VIEW",
payload: {
issueView: property,
},
});
if (property === "kanban") {
dispatch({
type: "SET_GROUP_BY_PROPERTY",
payload: {
groupByProperty: "state_detail.group",
},
});
}
}, []);
const setGroupByProperty = useCallback((property: TIssueGroupByOptions) => {
dispatch({
type: "SET_GROUP_BY_PROPERTY",
payload: {
groupByProperty: property,
},
});
}, []);
const setOrderBy = useCallback((property: TIssueOrderByOptions) => {
dispatch({
type: "SET_ORDER_BY_PROPERTY",
payload: {
orderBy: property,
},
});
}, []);
const setShowEmptyGroups = useCallback((property: boolean) => {
dispatch({
type: "SET_SHOW_EMPTY_STATES",
payload: {
showEmptyGroups: property,
},
});
}, []);
const setShowSubIssues = useCallback((property: boolean) => {
dispatch({
type: "SET_SHOW_SUB_ISSUES",
payload: {
showSubIssues: property,
},
});
}, []);
const setFilters = useCallback(
(property: Partial<IIssueFilterOptions>) => {
Object.keys(property).forEach((key) => {
if (property[key as keyof typeof property]?.length === 0)
property[key as keyof typeof property] = null;
});
dispatch({
type: "SET_FILTERS",
payload: {
filters: {
...state.filters,
...property,
},
},
});
},
[state]
);
const setProperties = useCallback(
(key: keyof Properties) => {
dispatch({
type: "SET_PROPERTIES",
payload: {
properties: {
...state.properties,
[key]: !state.properties[key],
},
},
});
},
[state]
);
return (
<profileIssuesContext.Provider
value={{
issueView: state.issueView,
setIssueView,
groupByProperty: state.groupByProperty,
setGroupByProperty,
orderBy: state.orderBy,
setOrderBy,
showEmptyGroups: state.showEmptyGroups,
setShowEmptyGroups,
showSubIssues: state.showSubIssues,
setShowSubIssues,
filters: state.filters,
setFilters,
properties: state.properties,
setProperties,
}}
>
<ToastAlert />
{children}
</profileIssuesContext.Provider>
);
};

View File

@ -0,0 +1,312 @@
// icons
import { Icon } from "components/ui";
import { Squares2X2Icon } from "@heroicons/react/24/outline";
import { BlockedIcon, BlockerIcon } from "components/icons";
// helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
import { capitalizeFirstLetter } from "helpers/string.helper";
// types
import { IIssueActivity } from "types";
export const activityDetails: {
[key: string]: {
message: (activity: IIssueActivity) => React.ReactNode;
icon: React.ReactNode;
};
} = {
assignees: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
added a new assignee{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the assignee{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <Icon iconName="group" className="!text-sm" aria-hidden="true" />,
},
archived_at: {
message: (activity) => {
if (activity.new_value === "restore") return "restored the issue.";
else return "archived the issue.";
},
icon: <Icon iconName="archive" className="!text-sm" aria-hidden="true" />,
},
attachment: {
message: (activity) => {
if (activity.verb === "created")
return (
<>
uploaded a new{" "}
<a
href={`${activity.new_value}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
attachment
<Icon iconName="launch" className="!text-xs" />
</a>
</>
);
else return "removed an attachment.";
},
icon: <Icon iconName="attach_file" className="!text-sm" aria-hidden="true" />,
},
blocking: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked this issue is blocking issue{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the blocking issue{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <BlockerIcon height="12" width="12" color="#6b7280" />,
},
blocks: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
marked this issue is being blocked by{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed this issue being blocked by issue{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <BlockedIcon height="12" width="12" color="#6b7280" />,
},
cycles: {
message: (activity) => {
if (activity.verb === "created")
return (
<>
added this issue to the cycle{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else if (activity.verb === "updated")
return (
<>
set the cycle to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the issue from the cycle{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <Icon iconName="contrast" className="!text-sm" aria-hidden="true" />,
},
description: {
message: (activity) => "updated the description.",
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
},
estimate_point: {
message: (activity) => {
if (!activity.new_value) return "removed the estimate point.";
else
return (
<>
set the estimate point to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
},
icon: <Icon iconName="change_history" className="!text-sm" aria-hidden="true" />,
},
issue: {
message: (activity) => {
if (activity.verb === "created") return "created the issue.";
else return "deleted an issue.";
},
icon: <Icon iconName="stack" className="!text-sm" aria-hidden="true" />,
},
labels: {
message: (activity) => {
if (activity.old_value === "")
return (
<>
added a new label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
</span>
</>
);
else
return (
<>
removed the label{" "}
<span className="inline-flex items-center gap-3 rounded-full border border-custom-border-300 px-2 py-0.5 text-xs">
<span
className="h-1.5 w-1.5 rounded-full"
style={{
backgroundColor: "#000000",
}}
aria-hidden="true"
/>
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
</span>
</>
);
},
icon: <Icon iconName="sell" className="!text-sm" aria-hidden="true" />,
},
link: {
message: (activity) => {
if (activity.verb === "created")
return (
<>
added this{" "}
<a
href={`${activity.new_value}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
link
<Icon iconName="launch" className="!text-xs" />
</a>{" "}
to the issue.
</>
);
else
return (
<>
removed this{" "}
<a
href={`${activity.old_value}`}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline"
>
link
<Icon iconName="launch" className="!text-xs" />
</a>{" "}
from the issue.
</>
);
},
icon: <Icon iconName="link" className="!text-sm" aria-hidden="true" />,
},
modules: {
message: (activity) => {
if (activity.verb === "created")
return (
<>
added this issue to the module{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else if (activity.verb === "updated")
return (
<>
set the module to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
else
return (
<>
removed the issue from the module{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
},
icon: <Icon iconName="dataset" className="!text-sm" aria-hidden="true" />,
},
name: {
message: (activity) => `set the name to ${activity.new_value}.`,
icon: <Icon iconName="chat" className="!text-sm" aria-hidden="true" />,
},
parent: {
message: (activity) => {
if (!activity.new_value)
return (
<>
removed the parent{" "}
<span className="font-medium text-custom-text-100">{activity.old_value}</span>.
</>
);
else
return (
<>
set the parent to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
);
},
icon: <Icon iconName="supervised_user_circle" className="!text-sm" aria-hidden="true" />,
},
priority: {
message: (activity) => (
<>
set the priority to{" "}
<span className="font-medium text-custom-text-100">
{activity.new_value ? capitalizeFirstLetter(activity.new_value) : "None"}
</span>
.
</>
),
icon: <Icon iconName="signal_cellular_alt" className="!text-sm" aria-hidden="true" />,
},
state: {
message: (activity) => (
<>
set the state to{" "}
<span className="font-medium text-custom-text-100">{activity.new_value}</span>.
</>
),
icon: <Squares2X2Icon className="h-3 w-3" aria-hidden="true" />,
},
target_date: {
message: (activity) => {
if (!activity.new_value) return "removed the due date.";
else
return (
<>
set the due date to{" "}
<span className="font-medium text-custom-text-100">
{renderShortDateWithYearFormat(activity.new_value)}
</span>
.
</>
);
},
icon: <Icon iconName="calendar_today" className="!text-sm" aria-hidden="true" />,
},
};

View File

@ -62,7 +62,7 @@ const useIssuesView = () => {
sub_issue: showSubIssues,
};
const { data: projectIssues } = useSWR(
const { data: projectIssues, mutate: mutateProjectIssues } = useSWR(
workspaceSlug && projectId && params
? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params)
: null,
@ -72,7 +72,7 @@ const useIssuesView = () => {
: null
);
const { data: projectArchivedIssues } = useSWR(
const { data: projectArchivedIssues, mutate: mutateProjectArchivedIssues } = useSWR(
workspaceSlug && projectId && params && isArchivedIssues && !archivedIssueId
? PROJECT_ARCHIVED_ISSUES_LIST_WITH_PARAMS(projectId as string, params)
: null,
@ -81,7 +81,7 @@ const useIssuesView = () => {
: null
);
const { data: cycleIssues } = useSWR(
const { data: cycleIssues, mutate: mutateCycleIssues } = useSWR(
workspaceSlug && projectId && cycleId && params
? CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)
: null,
@ -96,7 +96,7 @@ const useIssuesView = () => {
: null
);
const { data: moduleIssues } = useSWR(
const { data: moduleIssues, mutate: mutateModuleIssues } = useSWR(
workspaceSlug && projectId && moduleId && params
? MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)
: null,
@ -111,7 +111,7 @@ const useIssuesView = () => {
: null
);
const { data: viewIssues } = useSWR(
const { data: viewIssues, mutate: mutateViewIssues } = useSWR(
workspaceSlug && projectId && viewId && params ? VIEW_ISSUES(viewId.toString(), params) : null,
workspaceSlug && projectId && viewId && params
? () =>
@ -187,6 +187,15 @@ const useIssuesView = () => {
return {
groupedByIssues,
mutateIssues: cycleId
? mutateCycleIssues
: moduleId
? mutateModuleIssues
: viewId
? mutateViewIssues
: isArchivedIssues
? mutateProjectArchivedIssues
: mutateProjectIssues,
issueView: isArchivedIssues ? "list" : issueView,
groupByProperty,
setGroupByProperty,

View File

@ -0,0 +1,126 @@
import { useContext, useEffect, useMemo } from "react";
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import userService from "services/user.service";
// contexts
import { profileIssuesContext } from "contexts/profile-issues-context";
// types
import { IIssue } from "types";
// fetch-keys
import { USER_PROFILE_ISSUES } from "constants/fetch-keys";
const useProfileIssues = (workspaceSlug: string | undefined, userId: string | undefined) => {
const {
issueView,
setIssueView,
groupByProperty,
setGroupByProperty,
orderBy,
setOrderBy,
showEmptyGroups,
setShowEmptyGroups,
showSubIssues,
setShowSubIssues,
filters,
setFilters,
properties,
setProperties,
} = useContext(profileIssuesContext);
const router = useRouter();
const params: any = {
assignees: filters?.assignees ? filters?.assignees.join(",") : undefined,
created_by: filters?.created_by ? filters?.created_by.join(",") : undefined,
group_by: groupByProperty,
labels: filters?.labels ? filters?.labels.join(",") : undefined,
order_by: orderBy,
priority: filters?.priority ? filters?.priority.join(",") : undefined,
state_group: filters?.state_group ? filters?.state_group.join(",") : undefined,
target_date: filters?.target_date ? filters?.target_date.join(",") : undefined,
type: filters?.type ? filters?.type : undefined,
subscriber: filters?.subscriber ? filters?.subscriber : undefined,
};
const { data: userProfileIssues, mutate: mutateProfileIssues } = useSWR(
workspaceSlug && userId
? USER_PROFILE_ISSUES(workspaceSlug.toString(), userId.toString(), params)
: null,
workspaceSlug && userId
? () => userService.getUserProfileIssues(workspaceSlug.toString(), userId.toString(), params)
: null
);
const groupedIssues:
| {
[key: string]: IIssue[];
}
| undefined = useMemo(() => {
if (!userProfileIssues) return undefined;
if (Array.isArray(userProfileIssues))
return {
allIssues: userProfileIssues,
};
return userProfileIssues;
}, [userProfileIssues]);
useEffect(() => {
if (!userId || !filters) return;
console.log("Triggered");
if (
router.pathname.includes("assigned") &&
(!filters.assignees || !filters.assignees.includes(userId))
) {
setFilters({ assignees: [...(filters.assignees ?? []), userId] });
return;
}
if (
router.pathname.includes("created") &&
(!filters.created_by || !filters.created_by.includes(userId))
) {
setFilters({ created_by: [...(filters.created_by ?? []), userId] });
return;
}
if (router.pathname.includes("subscribed") && filters.subscriber === null) {
setFilters({ subscriber: userId });
return;
}
}, [filters, router, setFilters, userId]);
const isEmpty =
Object.values(groupedIssues ?? {}).every((group) => group.length === 0) ||
Object.keys(groupedIssues ?? {}).length === 0;
return {
groupedIssues,
issueView,
setIssueView,
groupByProperty,
setGroupByProperty,
orderBy,
setOrderBy,
showEmptyGroups,
setShowEmptyGroups,
showSubIssues,
setShowSubIssues,
filters,
setFilters,
properties,
setProperties,
isEmpty,
mutateProfileIssues,
params,
};
};
export default useProfileIssues;

View File

@ -6,12 +6,18 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys";
// hooks
import useUser from "./use-user";
const useProjectMembers = (workspaceSlug: string | undefined, projectId: string | undefined) => {
const useProjectMembers = (
workspaceSlug: string | undefined,
projectId: string | undefined,
fetchCondition?: boolean
) => {
fetchCondition = fetchCondition ?? true;
const { user } = useUser();
// fetching project members
const { data: members } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId) : null,
workspaceSlug && projectId
workspaceSlug && projectId && fetchCondition ? PROJECT_MEMBERS(projectId) : null,
workspaceSlug && projectId && fetchCondition
? () => projectService.projectMembers(workspaceSlug, projectId)
: null
);

View File

@ -87,7 +87,7 @@ const MyIssuesPage: NextPage = () => {
}
>
<div className="h-full w-full flex flex-col overflow-hidden">
<div className="border-b border-custom-border-300">
<div className="px-4 sm:px-5 border-b border-custom-border-300">
<div className="flex items-center overflow-x-scroll">
{tabsList.map((tab) => (
<button

View File

@ -0,0 +1,48 @@
import React from "react";
import { useRouter } from "next/router";
// contexts
import { ProfileIssuesContextProvider } from "contexts/profile-issues-context";
// hooks
import useUser from "hooks/use-user";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
// components
import { ProfileIssuesView, ProfileNavbar, ProfileSidebar } from "components/profile";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types
import type { NextPage } from "next";
const ProfileAssignedIssues: NextPage = () => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { user } = useUser();
return (
<ProfileIssuesContextProvider>
<WorkspaceAuthorizationLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Settings" link={`/${workspaceSlug}/me/profile`} />
<BreadcrumbItem title={`${user?.first_name} ${user?.last_name}`} />
</Breadcrumbs>
}
>
<div className="h-full w-full flex overflow-hidden">
<div className="h-full w-full flex flex-col overflow-hidden">
<ProfileNavbar />
<div className="h-full w-full flex flex-col overflow-hidden">
<ProfileIssuesView />
</div>
</div>
<ProfileSidebar />
</div>
</WorkspaceAuthorizationLayout>
</ProfileIssuesContextProvider>
);
};
export default ProfileAssignedIssues;

View File

@ -0,0 +1,48 @@
import React from "react";
import { useRouter } from "next/router";
// contexts
import { ProfileIssuesContextProvider } from "contexts/profile-issues-context";
// hooks
import useUser from "hooks/use-user";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
// components
import { ProfileIssuesView, ProfileNavbar, ProfileSidebar } from "components/profile";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types
import type { NextPage } from "next";
const ProfileCreatedIssues: NextPage = () => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { user } = useUser();
return (
<ProfileIssuesContextProvider>
<WorkspaceAuthorizationLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Settings" link={`/${workspaceSlug}/me/profile`} />
<BreadcrumbItem title={`${user?.first_name} ${user?.last_name}`} />
</Breadcrumbs>
}
>
<div className="h-full w-full flex overflow-hidden">
<div className="h-full w-full flex flex-col overflow-hidden">
<ProfileNavbar />
<div className="h-full w-full flex flex-col overflow-hidden">
<ProfileIssuesView />
</div>
</div>
<ProfileSidebar />
</div>
</WorkspaceAuthorizationLayout>
</ProfileIssuesContextProvider>
);
};
export default ProfileCreatedIssues;

View File

@ -0,0 +1,152 @@
import React from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import useSWR from "swr";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
// services
import userService from "services/user.service";
// components
import {
ProfileNavbar,
ProfilePriorityDistribution,
ProfileSidebar,
ProfileStateDistribution,
ProfileStats,
ProfileWorkload,
} from "components/profile";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
import { Icon, Loader } from "components/ui";
// helpers
import { activityDetails } from "helpers/activity.helper";
import { timeAgo } from "helpers/date-time.helper";
// types
import type { NextPage } from "next";
import { IUserStateDistribution, TStateGroups } from "types";
// constants
import { USER_PROFILE_DATA, USER_PROFILE_ACTIVITY } from "constants/fetch-keys";
import { GROUP_CHOICES } from "constants/project";
const ProfileOverview: NextPage = () => {
const router = useRouter();
const { workspaceSlug, userId } = router.query;
const { data: userProfile } = useSWR(
workspaceSlug && userId ? USER_PROFILE_DATA(workspaceSlug.toString(), userId.toString()) : null,
workspaceSlug && userId
? () => userService.getUserProfileData(workspaceSlug.toString(), userId.toString())
: null
);
const { data: userProfileActivity } = useSWR(
workspaceSlug && userId
? USER_PROFILE_ACTIVITY(workspaceSlug.toString(), userId.toString())
: null,
workspaceSlug && userId
? () => userService.getUserProfileActivity(workspaceSlug.toString(), userId.toString())
: null
);
const stateDistribution: IUserStateDistribution[] = Object.keys(GROUP_CHOICES).map((key) => {
const group = userProfile?.state_distribution.find((g) => g.state_group === key);
if (group) return group;
else return { state_group: key as TStateGroups, state_count: 0 };
});
return (
<WorkspaceAuthorizationLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Projects" link={`/${workspaceSlug}/projects`} />
<BreadcrumbItem title={`User Name`} />
</Breadcrumbs>
}
>
<div className="h-full w-full flex overflow-hidden">
<div className="h-full w-full flex flex-col overflow-hidden">
<ProfileNavbar />
<div className="h-full w-full overflow-y-auto px-9 py-5 space-y-7">
<ProfileStats userProfile={userProfile} />
<ProfileWorkload stateDistribution={stateDistribution} />
<div className="grid grid-cols-1 xl:grid-cols-2 items-stretch gap-5">
<ProfilePriorityDistribution userProfile={userProfile} />
<ProfileStateDistribution
stateDistribution={stateDistribution}
userProfile={userProfile}
/>
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium">Recent Activity</h3>
<div className="border border-custom-border-100 rounded p-6">
{userProfileActivity ? (
<div className="space-y-5">
{userProfileActivity.results.map((activity) => (
<div key={activity.id} className="flex gap-3">
<div className="flex-shrink-0">
{activity.actor_detail.avatar && activity.actor_detail.avatar !== "" ? (
<img
src={activity.actor_detail.avatar}
alt={activity.actor_detail.first_name}
height={24}
width={24}
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.first_name.charAt(0)}
</div>
)}
</div>
<div className="-mt-1">
<p className="text-sm text-custom-text-200">
<span className="font-medium text-custom-text-100">
{activity.actor_detail.first_name} {activity.actor_detail.last_name}{" "}
</span>
{activity.field ? (
activityDetails[activity.field]?.message(activity as any)
) : (
<span>
created this{" "}
<Link
href={`/${activity.workspace_detail.slug}/projects/${activity.project}/issues/${activity.issue}`}
>
<a className="font-medium text-custom-text-100 inline-flex items-center gap-1 hover:underline">
Issue
<Icon iconName="launch" className="!text-xs" />
</a>
</Link>
</span>
)}
</p>
<p className="text-xs text-custom-text-200">
{timeAgo(activity.created_at)}
</p>
</div>
</div>
))}
</div>
) : (
<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>
)}
</div>
</div>
</div>
</div>
<ProfileSidebar />
</div>
</WorkspaceAuthorizationLayout>
);
};
export default ProfileOverview;

View File

@ -0,0 +1,48 @@
import React from "react";
import { useRouter } from "next/router";
// contexts
import { ProfileIssuesContextProvider } from "contexts/profile-issues-context";
// hooks
import useUser from "hooks/use-user";
// layouts
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
// components
import { ProfileIssuesView, ProfileNavbar, ProfileSidebar } from "components/profile";
// ui
import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs";
// types
import type { NextPage } from "next";
const ProfileSubscribedIssues: NextPage = () => {
const router = useRouter();
const { workspaceSlug } = router.query;
const { user } = useUser();
return (
<ProfileIssuesContextProvider>
<WorkspaceAuthorizationLayout
breadcrumbs={
<Breadcrumbs>
<BreadcrumbItem title="Settings" link={`/${workspaceSlug}/me/profile`} />
<BreadcrumbItem title={`${user?.first_name} ${user?.last_name}`} />
</Breadcrumbs>
}
>
<div className="h-full w-full flex overflow-hidden">
<div className="h-full w-full flex flex-col overflow-hidden">
<ProfileNavbar />
<div className="h-full w-full flex flex-col overflow-hidden">
<ProfileIssuesView />
</div>
</div>
<ProfileSidebar />
</div>
</WorkspaceAuthorizationLayout>
</ProfileIssuesContextProvider>
);
};
export default ProfileSubscribedIssues;

View File

@ -7,6 +7,8 @@ import type {
IIssue,
IUser,
IUserActivityResponse,
IUserProfileData,
IUserProfileProjectSegregation,
IUserWorkspaceDashboard,
} from "types";
@ -144,6 +146,55 @@ class UserService extends APIService {
throw error?.response?.data;
});
}
async getUserProfileData(workspaceSlug: string, userId: string): Promise<IUserProfileData> {
return this.get(`/api/workspaces/${workspaceSlug}/user-stats/${userId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getUserProfileProjectsSegregation(
workspaceSlug: string,
userId: string
): Promise<IUserProfileProjectSegregation> {
return this.get(`/api/workspaces/${workspaceSlug}/user-profile/${userId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getUserProfileActivity(
workspaceSlug: string,
userId: string
): Promise<IUserActivityResponse> {
return this.get(`/api/workspaces/${workspaceSlug}/user-activity/${userId}/?per_page=15`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getUserProfileIssues(
workspaceSlug: string,
userId: string,
params: any
): Promise<
| {
[key: string]: IIssue[];
}
| IIssue[]
> {
return this.get(`/api/workspaces/${workspaceSlug}/user-issues/${userId}/`, {
params,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}
export default new UserService();

View File

@ -1,3 +1,4 @@
import { KeyedMutator } from "swr";
import type {
IState,
IUser,
@ -292,6 +293,12 @@ export interface IIssueViewProps {
groupByProperty: TIssueGroupByOptions;
isEmpty: boolean;
issueView: TIssueViewOptions;
mutateIssues: KeyedMutator<
| IIssue[]
| {
[key: string]: IIssue[];
}
>;
orderBy: TIssueOrderByOptions;
params: any;
properties: Properties;

View File

@ -1,4 +1,12 @@
import { IIssue, IIssueLite, IWorkspace, NestedKeyOf, Properties } from "./";
import {
IIssue,
IIssueLite,
IWorkspace,
IWorkspaceLite,
NestedKeyOf,
Properties,
TStateGroups,
} from "./";
export interface IUser {
avatar: string;
@ -51,7 +59,6 @@ export interface ICurrentUserResponse extends IUser {
last_workspace_slug: string | null;
};
}
export interface IUserLite {
avatar: string;
created_at: Date;
@ -67,8 +74,13 @@ export interface IUserActivity {
activity_count: number;
}
export interface IUserPriorityDistribution {
priority: string;
priority_count: number;
}
export interface IUserStateDistribution {
state_group: string;
state_group: TStateGroups;
state_count: number;
}
@ -107,6 +119,7 @@ export interface IUserDetailedActivity {
updated_by: string | null;
verb: string;
workspace: string;
workspace_detail: IWorkspaceLite;
}
export interface IUserActivityResponse {
@ -133,3 +146,36 @@ export type OnboardingSteps = {
workspace_invite: boolean;
workspace_join: boolean;
};
export interface IUserProfileData {
assigned_issues: number;
completed_issues: number;
created_issues: number;
pending_issues: number;
priority_distribution: IUserPriorityDistribution[];
state_distribution: IUserStateDistribution[];
subscribed_issues: number;
}
export interface IUserProfileProjectSegregation {
project_data: {
assigned_issues: number;
completed_issues: number;
created_issues: number;
emoji: string | null;
icon_prop: null;
id: string;
identifier: string;
name: string;
pending_issues: number;
}[];
user_data: {
avatar: string;
cover_image: string | null;
date_joined: Date;
email: string;
first_name: string;
last_name: string;
user_timezone: string;
};
}