From 2e5e14556daa652e5da0227cb55ec02151e3810c Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 12 Mar 2024 12:00:18 +0530 Subject: [PATCH 01/13] feat: test email script (#3937) * feat: script for testing emails * chore: rename subject and body for the mail --- .../db/management/commands/test_email.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 apiserver/plane/db/management/commands/test_email.py diff --git a/apiserver/plane/db/management/commands/test_email.py b/apiserver/plane/db/management/commands/test_email.py new file mode 100644 index 000000000..d36a784d0 --- /dev/null +++ b/apiserver/plane/db/management/commands/test_email.py @@ -0,0 +1,61 @@ +from django.core.mail import EmailMultiAlternatives, get_connection +from django.core.management import BaseCommand, CommandError + +from plane.license.utils.instance_value import get_email_configuration + + +class Command(BaseCommand): + """Django command to pause execution until db is available""" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument("to_email", type=str, help="receiver's email") + + def handle(self, *args, **options): + receiver_email = options.get("to_email") + + if not receiver_email: + raise CommandError("Reciever email is required") + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + timeout=30, + ) + # Prepare email details + subject = "Email Notification from Plane" + message = ( + "This is a sample email notification sent from Plane application." + ) + + self.stdout.write(self.style.SUCCESS("Trying to send test email...")) + + # Send the email + try: + msg = EmailMultiAlternatives( + subject=subject, + body=message, + from_email=EMAIL_FROM, + to=[receiver_email], + connection=connection, + ) + msg.send() + self.stdout.write(self.style.SUCCESS("Email succesfully sent")) + except Exception as e: + self.stdout.write( + self.style.ERROR( + f"Error: Email could not be delivered due to {e}" + ) + ) From 578bd29f6f65db022493111b0a1d25b4a9bd7fd8 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Tue, 12 Mar 2024 12:24:58 +0530 Subject: [PATCH 02/13] chore: active issue total count (#3936) --- apiserver/plane/app/views/cycle/base.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 42904a8fc..189cdb096 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -192,7 +192,15 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) def list(self, request, slug, project_id): - queryset = self.get_queryset() + queryset = self.get_queryset().annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) cycle_view = request.GET.get("cycle_view", "all") # Update the order by @@ -223,6 +231,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "progress_snapshot", # meta fields "is_favorite", + "total_issues", "cancelled_issues", "completed_issues", "started_issues", From 73c91654eb82a21b8d2e67d6a71bf22a0f053d8f Mon Sep 17 00:00:00 2001 From: Ramesh Kumar Chandra <31303617+rameshkumarchandra@users.noreply.github.com> Date: Tue, 12 Mar 2024 12:29:00 +0530 Subject: [PATCH 03/13] [WEB-716] style: list view issues responsiveness (#3938) --- .../issues/issue-layouts/list/block.tsx | 81 ++++++++++--------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 099137348..bab55a639 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -44,53 +44,62 @@ export const IssueBlock: React.FC = observer((props: IssueBlock return (
- {displayProperties && displayProperties?.key && ( -
- {projectIdentifier}-{issue.sequence_id} +
+
+ {displayProperties && displayProperties?.key && ( +
+ {projectIdentifier}-{issue.sequence_id} +
+ )} + + {issue?.tempId !== undefined && ( +
+ )} + + {issue?.is_draft ? ( + + {issue.name} + + ) : ( + handleIssuePeekOverview(issue)} + className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + disabled={!!issue?.tempId} + > + + {issue.name} + + + )}
- )} - - {issue?.tempId !== undefined && ( -
- )} - - {issue?.is_draft ? ( - - {issue.name} - - ) : ( - handleIssuePeekOverview(issue)} - className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" - disabled={!!issue?.tempId} - > - - {issue.name} - - - )} - -
+ {!issue?.tempId && ( +
{quickActions(issue)}
+ )} +
+
{!issue?.tempId ? ( <> - {quickActions(issue)} +
{quickActions(issue)}
) : (
From 5c4c3f5c041dbfb09f3480cb6f20eb70cb5cb41d Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 12 Mar 2024 19:05:15 +0530 Subject: [PATCH 04/13] fix: project page empty state (#3939) --- web/components/pages/pages-list/list-view.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/pages/pages-list/list-view.tsx b/web/components/pages/pages-list/list-view.tsx index 8c1a09e73..6b363b2f2 100644 --- a/web/components/pages/pages-list/list-view.tsx +++ b/web/components/pages/pages-list/list-view.tsx @@ -29,7 +29,7 @@ export const PagesListView: FC = (props) => { // here we are only observing the projectPageStore, so that we can re-render the component when the projectPageStore changes - const emptyStateType = pageTab ? `project-page-${pageTab}` : EmptyStateType.PROJECT_PAGE_ALL; + const emptyStateType = pageTab ? `project-page-${pageTab.toLowerCase()}` : EmptyStateType.PROJECT_PAGE_ALL; const isButtonVisible = pageTab !== "archived" && pageTab !== "favorites"; return ( From f77f4b8221774e0d3da80e830e6c97280c516a23 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 12 Mar 2024 19:07:12 +0530 Subject: [PATCH 05/13] fix: gantt sidebar links (#3940) --- web/components/cycles/gantt-chart/blocks.tsx | 33 +++++++++---------- .../gantt-chart/chart/views/month.tsx | 4 +-- web/components/modules/gantt-chart/blocks.tsx | 11 +++---- 3 files changed, 22 insertions(+), 26 deletions(-) diff --git a/web/components/cycles/gantt-chart/blocks.tsx b/web/components/cycles/gantt-chart/blocks.tsx index e9fdd50de..cae849839 100644 --- a/web/components/cycles/gantt-chart/blocks.tsx +++ b/web/components/cycles/gantt-chart/blocks.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // hooks @@ -33,12 +34,12 @@ export const CycleGanttBlock: React.FC = observer((props) => { cycleStatus === "current" ? "#09a953" : cycleStatus === "upcoming" - ? "#f7ae59" - : cycleStatus === "completed" - ? "#3f76ff" - : cycleStatus === "draft" - ? "rgb(var(--color-text-200))" - : "", + ? "#f7ae59" + : cycleStatus === "completed" + ? "#3f76ff" + : cycleStatus === "draft" + ? "rgb(var(--color-text-200))" + : "", }} onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)} > @@ -63,8 +64,6 @@ export const CycleGanttBlock: React.FC = observer((props) => { export const CycleGanttSidebarBlock: React.FC = observer((props) => { const { cycleId } = props; - // router - const router = useRouter(); // store hooks const { router: { workspaceSlug }, @@ -76,9 +75,9 @@ export const CycleGanttSidebarBlock: React.FC = observer((props) => { const cycleStatus = cycleDetails?.status.toLocaleLowerCase(); return ( -
router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)} + href={`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`} > = observer((props) => { cycleStatus === "current" ? "#09a953" : cycleStatus === "upcoming" - ? "#f7ae59" - : cycleStatus === "completed" - ? "#3f76ff" - : cycleStatus === "draft" - ? "rgb(var(--color-text-200))" - : "" + ? "#f7ae59" + : cycleStatus === "completed" + ? "#3f76ff" + : cycleStatus === "draft" + ? "rgb(var(--color-text-200))" + : "" }`} />
{cycleDetails?.name}
-
+ ); }); diff --git a/web/components/gantt-chart/chart/views/month.tsx b/web/components/gantt-chart/chart/views/month.tsx index b09bcc671..b5787eb61 100644 --- a/web/components/gantt-chart/chart/views/month.tsx +++ b/web/components/gantt-chart/chart/views/month.tsx @@ -19,7 +19,7 @@ export const MonthChartView: FC = observer(() => { {monthBlocks?.map((block, rootIndex) => (
= observer(() => { ))}
-
+
{block?.children?.map((monthDay, index) => (
= observer((props) => { export const ModuleGanttSidebarBlock: React.FC = observer((props) => { const { moduleId } = props; - // router - const router = useRouter(); // store hooks const { router: { workspaceSlug }, @@ -65,14 +64,12 @@ export const ModuleGanttSidebarBlock: React.FC = observer((props) => { const moduleDetails = getModuleById(moduleId); return ( -
- router.push(`/${workspaceSlug}/projects/${moduleDetails?.project_id}/modules/${moduleDetails?.id}`) - } + href={`/${workspaceSlug}/projects/${moduleDetails?.project_id}/modules/${moduleDetails?.id}`} >
{moduleDetails?.name}
-
+ ); }); From 730e556bea8938ae2cde8cd7486b008e972513b5 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 12 Mar 2024 19:09:51 +0530 Subject: [PATCH 06/13] fix: emoji picker unnecessary scroll (#3941) --- packages/ui/src/emoji/emoji-icon-picker.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/emoji/emoji-icon-picker.tsx b/packages/ui/src/emoji/emoji-icon-picker.tsx index 42c367938..5bfcdbe17 100644 --- a/packages/ui/src/emoji/emoji-icon-picker.tsx +++ b/packages/ui/src/emoji/emoji-icon-picker.tsx @@ -103,7 +103,7 @@ export const CustomEmojiIconPicker: React.FC = (props) => { style={styles.popper} {...attributes.popper} className={cn( - "h-80 w-80 bg-custom-background-100 rounded-md border-[0.5px] border-custom-border-300 overflow-hidden", + "w-80 bg-custom-background-100 rounded-md border-[0.5px] border-custom-border-300 overflow-hidden", dropdownClassName )} > @@ -146,7 +146,7 @@ export const CustomEmojiIconPicker: React.FC = (props) => { }} /> - + { From 443b93f897ed96c6dc52ecb1f097e12e4216e6dd Mon Sep 17 00:00:00 2001 From: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com> Date: Tue, 12 Mar 2024 19:10:35 +0530 Subject: [PATCH 07/13] fix: label mutation in peek-overview & issue detail (#3942) --- web/components/issues/issue-detail/label/label-list.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/components/issues/issue-detail/label/label-list.tsx b/web/components/issues/issue-detail/label/label-list.tsx index fdf94be28..093556a34 100644 --- a/web/components/issues/issue-detail/label/label-list.tsx +++ b/web/components/issues/issue-detail/label/label-list.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import { observer } from "mobx-react"; // components import { useIssueDetail } from "hooks/store"; import { LabelListItem } from "./label-list-item"; @@ -14,7 +15,7 @@ type TLabelList = { disabled: boolean; }; -export const LabelList: FC = (props) => { +export const LabelList: FC = observer((props) => { const { workspaceSlug, projectId, issueId, labelOperations, disabled } = props; // hooks const { @@ -40,4 +41,4 @@ export const LabelList: FC = (props) => { ))} ); -}; +}); From c97b994311daca9b84e4dea6ccd70d93c9f110f8 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Tue, 12 Mar 2024 19:11:36 +0530 Subject: [PATCH 08/13] [WEB-717] fix: delete issue modal UI fixes. (#3944) * fix: avoid closing the peek overview when the close button within the delete model popup is clicked. * fix: distortion in issue detail sidebar UI when any modal is open. --- web/components/issues/issue-detail/root.tsx | 2 +- web/components/issues/peek-overview/view.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/web/components/issues/issue-detail/root.tsx b/web/components/issues/issue-detail/root.tsx index 5e56170a8..25aa810cf 100644 --- a/web/components/issues/issue-detail/root.tsx +++ b/web/components/issues/issue-detail/root.tsx @@ -376,7 +376,7 @@ export const IssueDetailRoot: FC = observer((props) => { />
= observer((props) => { isOpen={isDeleteIssueModalOpen} handleClose={() => { toggleDeleteIssueModal(false); - removeRoutePeekId(); }} data={issue} onSubmit={() => issueOperations.remove(workspaceSlug, projectId, issueId)} From 8aca74c68df8b1b7918879c045f3cbaf0648b360 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 12 Mar 2024 19:12:25 +0530 Subject: [PATCH 09/13] [WEB-720] chore: reaction tooltip added (#3945) * chore: reaction tooltip added * chore: reaction tooltip updated * chore: issue reaction tooltip updated * chore: helper function updated --- .../issue-detail/reactions/issue-comment.tsx | 49 +++++++++++------ .../issues/issue-detail/reactions/issue.tsx | 53 ++++++++++++------- web/helpers/issue.helper.ts | 18 +++++++ 3 files changed, 85 insertions(+), 35 deletions(-) diff --git a/web/components/issues/issue-detail/reactions/issue-comment.tsx b/web/components/issues/issue-detail/reactions/issue-comment.tsx index 97c63a017..e26befe1b 100644 --- a/web/components/issues/issue-detail/reactions/issue-comment.tsx +++ b/web/components/issues/issue-detail/reactions/issue-comment.tsx @@ -1,10 +1,11 @@ import { FC, useMemo } from "react"; import { observer } from "mobx-react-lite"; // components -import { TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; import { renderEmoji } from "helpers/emoji.helper"; -import { useIssueDetail } from "hooks/store"; -// ui +import { useIssueDetail, useMember } from "hooks/store"; +// helper +import { formatTextList } from "helpers/issue.helper"; // types import { IUser } from "@plane/types"; import { ReactionSelector } from "./reaction-selector"; @@ -21,10 +22,11 @@ export const IssueCommentReaction: FC = observer((props) // hooks const { - commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser }, + commentReaction: { getCommentReactionsByCommentId, commentReactionsByUser, getCommentReactionById }, createCommentReaction, removeCommentReaction, } = useIssueDetail(); + const { getUserDetails } = useMember(); const reactionIds = getCommentReactionsByCommentId(commentId); const userReactions = commentReactionsByUser(commentId, currentUser.id).map((r) => r.reaction); @@ -73,6 +75,17 @@ export const IssueCommentReaction: FC = observer((props) [workspaceSlug, projectId, commentId, currentUser, createCommentReaction, removeCommentReaction, userReactions] ); + const getReactionUsers = (reaction: string): string => { + const reactionUsers = (reactionIds?.[reaction] || []) + .map((reactionId) => { + const reactionDetails = getCommentReactionById(reactionId); + return reactionDetails ? getUserDetails(reactionDetails.actor)?.display_name : null; + }) + .filter((displayName): displayName is string => !!displayName); + const formattedUsers = formatTextList(reactionUsers); + return formattedUsers; + }; + return (
= observer((props) (reaction) => reactionIds[reaction]?.length > 0 && ( <> - + + + ) )} diff --git a/web/components/issues/issue-detail/reactions/issue.tsx b/web/components/issues/issue-detail/reactions/issue.tsx index 6f5610634..c21f92139 100644 --- a/web/components/issues/issue-detail/reactions/issue.tsx +++ b/web/components/issues/issue-detail/reactions/issue.tsx @@ -1,10 +1,12 @@ import { FC, useMemo } from "react"; import { observer } from "mobx-react-lite"; -// components -import { TOAST_TYPE, setToast } from "@plane/ui"; -import { renderEmoji } from "helpers/emoji.helper"; -import { useIssueDetail } from "hooks/store"; +// hooks +import { useIssueDetail, useMember } from "hooks/store"; // ui +import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +// helpers +import { renderEmoji } from "helpers/emoji.helper"; +import { formatTextList } from "helpers/issue.helper"; // types import { IUser } from "@plane/types"; import { ReactionSelector } from "./reaction-selector"; @@ -20,10 +22,11 @@ export const IssueReaction: FC = observer((props) => { const { workspaceSlug, projectId, issueId, currentUser } = props; // hooks const { - reaction: { getReactionsByIssueId, reactionsByUser }, + reaction: { getReactionsByIssueId, reactionsByUser, getReactionById }, createReaction, removeReaction, } = useIssueDetail(); + const { getUserDetails } = useMember(); const reactionIds = getReactionsByIssueId(issueId); const userReactions = reactionsByUser(issueId, currentUser.id).map((r) => r.reaction); @@ -72,6 +75,18 @@ export const IssueReaction: FC = observer((props) => { [workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, userReactions] ); + const getReactionUsers = (reaction: string): string => { + const reactionUsers = (reactionIds?.[reaction] || []) + .map((reactionId) => { + const reactionDetails = getReactionById(reactionId); + return reactionDetails ? getUserDetails(reactionDetails.actor_id)?.display_name : null; + }) + .filter((displayName): displayName is string => !!displayName); + + const formattedUsers = formatTextList(reactionUsers); + return formattedUsers; + }; + return (
@@ -81,19 +96,21 @@ export const IssueReaction: FC = observer((props) => { (reaction) => reactionIds[reaction]?.length > 0 && ( <> - + + + ) )} diff --git a/web/helpers/issue.helper.ts b/web/helpers/issue.helper.ts index 3e6689151..cba9ced8d 100644 --- a/web/helpers/issue.helper.ts +++ b/web/helpers/issue.helper.ts @@ -184,3 +184,21 @@ export function getChangedIssuefields(formData: Partial, dirtyFields: { return changedFields; } + +export const formatTextList = (TextArray: string[]): string => { + const count = TextArray.length; + switch (count) { + case 0: + return ""; + case 1: + return TextArray[0]; + case 2: + return `${TextArray[0]} and ${TextArray[1]}`; + case 3: + return `${TextArray.slice(0, 2).join(", ")}, and ${TextArray[2]}`; + case 4: + return `${TextArray.slice(0, 3).join(", ")}, and ${TextArray[3]}`; + default: + return `${TextArray.slice(0, 3).join(", ")}, and +${count - 3} more`; + } +}; From c3c6ef883001774aaefa9df8af2e37c55ff0c350 Mon Sep 17 00:00:00 2001 From: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com> Date: Tue, 12 Mar 2024 19:13:04 +0530 Subject: [PATCH 10/13] chore: removed module tooltip from peek-overview & issue detail (#3946) --- web/components/dropdowns/module/index.tsx | 7 +++++-- web/components/issues/issue-detail/module-select.tsx | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/components/dropdowns/module/index.tsx b/web/components/dropdowns/module/index.tsx index 882604712..629aeba0e 100644 --- a/web/components/dropdowns/module/index.tsx +++ b/web/components/dropdowns/module/index.tsx @@ -47,6 +47,7 @@ type ButtonContentProps = { onChange: (moduleIds: string[]) => void; placeholder: string; showCount: boolean; + showTooltip?: boolean; value: string | string[] | null; }; @@ -60,6 +61,7 @@ const ButtonContent: React.FC = (props) => { onChange, placeholder, showCount, + showTooltip = false, value, } = props; // store hooks @@ -90,12 +92,12 @@ const ButtonContent: React.FC = (props) => { > {!hideIcon && } {!hideText && ( - + {moduleDetails?.name} )} {!disabled && ( - +
From 69e110f4a83da0b0886dd09f7feb4c4048212309 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 12 Mar 2024 19:36:40 +0530 Subject: [PATCH 11/13] [WEB-578] feat: projects list filtering and ordering (#3926) * style: project card UI updated * dev: initialize project filter store and types * chore: implemented filtering logic * chore: implemented ordering * chore: my projects filter added * chore: update created at date filter options * refactor: order by dropdown * style: revert project card UI * fix: project card z-index * fix: members filtering * fix: build errors --- packages/types/src/importer/index.d.ts | 2 +- packages/types/src/inbox/inbox-types.d.ts | 2 +- packages/types/src/index.d.ts | 2 +- packages/types/src/project/index.ts | 2 + .../types/src/project/project_filters.d.ts | 25 +++ .../types/src/{ => project}/projects.d.ts | 2 +- .../cycles/applied-filters/date.tsx | 4 +- .../cycles/dropdowns/filters/end-date.tsx | 6 +- .../cycles/dropdowns/filters/start-date.tsx | 6 +- web/components/headers/projects.tsx | 151 +++++++++++++++--- .../filters/applied-filters/date.tsx | 4 +- .../filters/header/filters/start-date.tsx | 6 +- .../filters/header/filters/target-date.tsx | 6 +- .../project/applied-filters/access.tsx | 36 +++++ .../project/applied-filters/date.tsx | 55 +++++++ .../project/applied-filters/index.ts | 4 + .../project/applied-filters/members.tsx | 46 ++++++ .../project/applied-filters/root.tsx | 113 +++++++++++++ web/components/project/card-list.tsx | 75 +++++---- web/components/project/card.tsx | 119 +++++++------- .../project/dropdowns/filters/access.tsx | 48 ++++++ .../project/dropdowns/filters/created-at.tsx | 64 ++++++++ .../project/dropdowns/filters/index.ts | 5 + .../project/dropdowns/filters/lead.tsx | 97 +++++++++++ .../project/dropdowns/filters/members.tsx | 97 +++++++++++ .../project/dropdowns/filters/root.tsx | 96 +++++++++++ web/components/project/dropdowns/index.ts | 2 + web/components/project/dropdowns/order-by.tsx | 74 +++++++++ web/components/project/index.ts | 2 + web/components/views/view-list-item.tsx | 1 + web/constants/filters.ts | 17 +- web/constants/project.ts | 40 +++-- web/helpers/filter.helper.ts | 27 +++- web/helpers/project.helper.ts | 61 ++++++- web/hooks/store/index.ts | 1 + web/hooks/store/use-project-filter.ts | 11 ++ web/pages/[workspaceSlug]/projects/index.tsx | 45 +++++- .../empty-state/project/all-filters.svg | 42 +++++ .../empty-state/project/name-filter.svg | 41 +++++ web/store/project/index.ts | 4 + web/store/project/project.store.ts | 49 +++--- web/store/project/project_filter.store.ts | 144 +++++++++++++++++ web/store/root.store.ts | 4 +- 43 files changed, 1452 insertions(+), 186 deletions(-) create mode 100644 packages/types/src/project/index.ts create mode 100644 packages/types/src/project/project_filters.d.ts rename packages/types/src/{ => project}/projects.d.ts (99%) create mode 100644 web/components/project/applied-filters/access.tsx create mode 100644 web/components/project/applied-filters/date.tsx create mode 100644 web/components/project/applied-filters/index.ts create mode 100644 web/components/project/applied-filters/members.tsx create mode 100644 web/components/project/applied-filters/root.tsx create mode 100644 web/components/project/dropdowns/filters/access.tsx create mode 100644 web/components/project/dropdowns/filters/created-at.tsx create mode 100644 web/components/project/dropdowns/filters/index.ts create mode 100644 web/components/project/dropdowns/filters/lead.tsx create mode 100644 web/components/project/dropdowns/filters/members.tsx create mode 100644 web/components/project/dropdowns/filters/root.tsx create mode 100644 web/components/project/dropdowns/index.ts create mode 100644 web/components/project/dropdowns/order-by.tsx create mode 100644 web/hooks/store/use-project-filter.ts create mode 100644 web/public/empty-state/project/all-filters.svg create mode 100644 web/public/empty-state/project/name-filter.svg create mode 100644 web/store/project/project_filter.store.ts diff --git a/packages/types/src/importer/index.d.ts b/packages/types/src/importer/index.d.ts index 877c07196..271d685b6 100644 --- a/packages/types/src/importer/index.d.ts +++ b/packages/types/src/importer/index.d.ts @@ -1,7 +1,7 @@ export * from "./github-importer"; export * from "./jira-importer"; -import { IProjectLite } from "../projects"; +import { IProjectLite } from "../project"; // types import { IUserLite } from "../users"; diff --git a/packages/types/src/inbox/inbox-types.d.ts b/packages/types/src/inbox/inbox-types.d.ts index 9db71c3ee..c3ec8461e 100644 --- a/packages/types/src/inbox/inbox-types.d.ts +++ b/packages/types/src/inbox/inbox-types.d.ts @@ -1,5 +1,5 @@ import { TIssue } from "../issues/base"; -import type { IProjectLite } from "../projects"; +import type { IProjectLite } from "../project"; export type TInboxIssueExtended = { completed_at: string | null; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index eeec266b5..6e6244451 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -2,7 +2,7 @@ export * from "./users"; export * from "./workspace"; export * from "./cycle"; export * from "./dashboard"; -export * from "./projects"; +export * from "./project"; export * from "./state"; export * from "./issues"; export * from "./modules"; diff --git a/packages/types/src/project/index.ts b/packages/types/src/project/index.ts new file mode 100644 index 000000000..ef7308bf7 --- /dev/null +++ b/packages/types/src/project/index.ts @@ -0,0 +1,2 @@ +export * from "./project_filters"; +export * from "./projects"; diff --git a/packages/types/src/project/project_filters.d.ts b/packages/types/src/project/project_filters.d.ts new file mode 100644 index 000000000..02ad09ee1 --- /dev/null +++ b/packages/types/src/project/project_filters.d.ts @@ -0,0 +1,25 @@ +export type TProjectOrderByOptions = + | "sort_order" + | "name" + | "-name" + | "created_at" + | "-created_at" + | "members_length" + | "-members_length"; + +export type TProjectDisplayFilters = { + my_projects?: boolean; + order_by?: TProjectOrderByOptions; +}; + +export type TProjectFilters = { + access?: string[] | null; + lead?: string[] | null; + members?: string[] | null; + created_at?: string[] | null; +}; + +export type TProjectStoredFilters = { + display_filters?: TProjectDisplayFilters; + filters?: TProjectFilters; +}; diff --git a/packages/types/src/projects.d.ts b/packages/types/src/project/projects.d.ts similarity index 99% rename from packages/types/src/projects.d.ts rename to packages/types/src/project/projects.d.ts index afae5199f..f310d9c66 100644 --- a/packages/types/src/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -7,7 +7,7 @@ import type { IWorkspace, IWorkspaceLite, TStateGroups, -} from "."; +} from ".."; export type TProjectLogoProps = { in_use: "emoji" | "icon"; diff --git a/web/components/cycles/applied-filters/date.tsx b/web/components/cycles/applied-filters/date.tsx index 0298f12d2..84ca45692 100644 --- a/web/components/cycles/applied-filters/date.tsx +++ b/web/components/cycles/applied-filters/date.tsx @@ -4,7 +4,7 @@ import { X } from "lucide-react"; import { renderFormattedDate } from "helpers/date-time.helper"; import { capitalizeFirstLetter } from "helpers/string.helper"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; type Props = { editable: boolean | undefined; @@ -18,7 +18,7 @@ export const AppliedDateFilters: React.FC = observer((props) => { const getDateLabel = (value: string): string => { let dateLabel = ""; - const dateDetails = DATE_FILTER_OPTIONS.find((d) => d.value === value); + const dateDetails = DATE_AFTER_FILTER_OPTIONS.find((d) => d.value === value); if (dateDetails) dateLabel = dateDetails.name; else { diff --git a/web/components/cycles/dropdowns/filters/end-date.tsx b/web/components/cycles/dropdowns/filters/end-date.tsx index 10a401500..0af92da41 100644 --- a/web/components/cycles/dropdowns/filters/end-date.tsx +++ b/web/components/cycles/dropdowns/filters/end-date.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite"; import { DateFilterModal } from "components/core"; import { FilterHeader, FilterOption } from "components/issues"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; type Props = { appliedFilters: string[] | null; @@ -21,7 +21,9 @@ export const FilterEndDate: React.FC = observer((props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); return ( <> diff --git a/web/components/cycles/dropdowns/filters/start-date.tsx b/web/components/cycles/dropdowns/filters/start-date.tsx index 87def7e29..3c47eb286 100644 --- a/web/components/cycles/dropdowns/filters/start-date.tsx +++ b/web/components/cycles/dropdowns/filters/start-date.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite"; import { DateFilterModal } from "components/core"; import { FilterHeader, FilterOption } from "components/issues"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; type Props = { appliedFilters: string[] | null; @@ -21,7 +21,9 @@ export const FilterStartDate: React.FC = observer((props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); return ( <> diff --git a/web/components/headers/projects.tsx b/web/components/headers/projects.tsx index 3810860aa..81f77cdbb 100644 --- a/web/components/headers/projects.tsx +++ b/web/components/headers/projects.tsx @@ -1,26 +1,81 @@ +import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; -import { Search, Plus, Briefcase } from "lucide-react"; +import { Search, Plus, Briefcase, X, ListFilter } from "lucide-react"; // hooks -// ui -import { Breadcrumbs, Button } from "@plane/ui"; -// constants +import { useApplication, useEventTracker, useMember, useProject, useProjectFilter, useUser } from "hooks/store"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// components import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +// ui +import { Breadcrumbs, Button } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// constants import { EUserWorkspaceRoles } from "constants/workspace"; -// components -import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; +import { FiltersDropdown } from "components/issues"; +import { ProjectFiltersSelection, ProjectOrderByDropdown } from "components/project"; +import { TProjectFilters } from "@plane/types"; export const ProjectsHeader = observer(() => { + // states + const [isSearchOpen, setIsSearchOpen] = useState(false); + // refs + const inputRef = useRef(null); // store hooks - const { commandPalette: commandPaletteStore } = useApplication(); + const { + commandPalette: commandPaletteStore, + router: { workspaceSlug }, + } = useApplication(); const { setTrackElement } = useEventTracker(); const { membership: { currentWorkspaceRole }, } = useUser(); - const { workspaceProjectIds, searchQuery, setSearchQuery } = useProject(); - + const { workspaceProjectIds } = useProject(); + const { + currentWorkspaceDisplayFilters: displayFilters, + currentWorkspaceFilters: filters, + updateFilters, + updateDisplayFilters, + searchQuery, + updateSearchQuery, + } = useProjectFilter(); + const { + workspace: { workspaceMemberIds }, + } = useMember(); + // outside click detector hook + useOutsideClickDetector(inputRef, () => { + if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); + }); + // auth const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; + const handleFilters = useCallback( + (key: keyof TProjectFilters, value: string | string[]) => { + if (!workspaceSlug) return; + const newValues = filters?.[key] ?? []; + + if (Array.isArray(value)) + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + else { + if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(workspaceSlug, { [key]: newValues }); + }, + [filters, updateFilters, workspaceSlug] + ); + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else setIsSearchOpen(false); + } + }; + return (
@@ -34,18 +89,74 @@ export const ProjectsHeader = observer(() => {
-
+
{workspaceProjectIds && workspaceProjectIds?.length > 0 && ( -
- - setSearchQuery(e.target.value)} - placeholder="Search" - /> +
+ {!isSearchOpen && ( + + )} +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
)} + { + if (!workspaceSlug || val === displayFilters?.order_by) return; + updateDisplayFilters(workspaceSlug, { + order_by: val, + }); + }} + /> + } title="Filters" placement="bottom-end"> + { + if (!workspaceSlug) return; + updateDisplayFilters(workspaceSlug, val); + }} + memberIds={workspaceMemberIds ?? undefined} + /> + {isAuthorizedUser && ( )}
diff --git a/web/components/issues/issue-layouts/filters/applied-filters/date.tsx b/web/components/issues/issue-layouts/filters/applied-filters/date.tsx index fdaed4b9b..24a197c76 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/date.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/date.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react-lite"; // icons import { X } from "lucide-react"; // helpers -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; import { renderFormattedDate } from "helpers/date-time.helper"; import { capitalizeFirstLetter } from "helpers/string.helper"; // constants @@ -18,7 +18,7 @@ export const AppliedDateFilters: React.FC = observer((props) => { const getDateLabel = (value: string): string => { let dateLabel = ""; - const dateDetails = DATE_FILTER_OPTIONS.find((d) => d.value === value); + const dateDetails = DATE_AFTER_FILTER_OPTIONS.find((d) => d.value === value); if (dateDetails) dateLabel = dateDetails.name; else { diff --git a/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx b/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx index 87def7e29..3c47eb286 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite"; import { DateFilterModal } from "components/core"; import { FilterHeader, FilterOption } from "components/issues"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; type Props = { appliedFilters: string[] | null; @@ -21,7 +21,9 @@ export const FilterStartDate: React.FC = observer((props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); return ( <> diff --git a/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx b/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx index 9e0ce18a7..83a526351 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react-lite"; import { DateFilterModal } from "components/core"; import { FilterHeader, FilterOption } from "components/issues"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; type Props = { appliedFilters: string[] | null; @@ -21,7 +21,9 @@ export const FilterTargetDate: React.FC = observer((props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); return ( <> diff --git a/web/components/project/applied-filters/access.tsx b/web/components/project/applied-filters/access.tsx new file mode 100644 index 000000000..bdb6ec053 --- /dev/null +++ b/web/components/project/applied-filters/access.tsx @@ -0,0 +1,36 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// constants +import { NETWORK_CHOICES } from "constants/project"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedAccessFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + + return ( + <> + {values.map((status) => { + const accessDetails = NETWORK_CHOICES.find((s) => `${s.key}` === status); + return ( +
+ {accessDetails?.label} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/components/project/applied-filters/date.tsx b/web/components/project/applied-filters/date.tsx new file mode 100644 index 000000000..aab0cf98a --- /dev/null +++ b/web/components/project/applied-filters/date.tsx @@ -0,0 +1,55 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// helpers +import { renderFormattedDate } from "helpers/date-time.helper"; +import { capitalizeFirstLetter } from "helpers/string.helper"; +// constants +import { DATE_BEFORE_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + editable: boolean | undefined; + handleRemove: (val: string) => void; + values: string[]; +}; + +export const AppliedDateFilters: React.FC = observer((props) => { + const { editable, handleRemove, values } = props; + + const getDateLabel = (value: string): string => { + let dateLabel = ""; + + const dateDetails = DATE_BEFORE_FILTER_OPTIONS.find((d) => d.value === value); + + if (dateDetails) dateLabel = dateDetails.name; + else { + const dateParts = value.split(";"); + + if (dateParts.length === 2) { + const [date, time] = dateParts; + + dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`; + } + } + + return dateLabel; + }; + + return ( + <> + {values.map((date) => ( +
+ {getDateLabel(date)} + {editable && ( + + )} +
+ ))} + + ); +}); diff --git a/web/components/project/applied-filters/index.ts b/web/components/project/applied-filters/index.ts new file mode 100644 index 000000000..818aa6134 --- /dev/null +++ b/web/components/project/applied-filters/index.ts @@ -0,0 +1,4 @@ +export * from "./access"; +export * from "./date"; +export * from "./members"; +export * from "./root"; diff --git a/web/components/project/applied-filters/members.tsx b/web/components/project/applied-filters/members.tsx new file mode 100644 index 000000000..88f18ee0c --- /dev/null +++ b/web/components/project/applied-filters/members.tsx @@ -0,0 +1,46 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// ui +import { Avatar } from "@plane/ui"; +// types +import { useMember } from "hooks/store"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedMembersFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + // store hooks + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + + return ( + <> + {values.map((memberId) => { + const memberDetails = getWorkspaceMemberDetails(memberId)?.member; + + if (!memberDetails) return null; + + return ( +
+ + {memberDetails.display_name} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/components/project/applied-filters/root.tsx b/web/components/project/applied-filters/root.tsx new file mode 100644 index 000000000..6e1cbf6a7 --- /dev/null +++ b/web/components/project/applied-filters/root.tsx @@ -0,0 +1,113 @@ +import { X } from "lucide-react"; +// components +import { AppliedAccessFilters, AppliedDateFilters, AppliedMembersFilters } from "components/project"; +// ui +import { Tooltip } from "@plane/ui"; +// helpers +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +// types +import { TProjectFilters } from "@plane/types"; + +type Props = { + appliedFilters: TProjectFilters; + handleClearAllFilters: () => void; + handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void; + alwaysAllowEditing?: boolean; + filteredProjects: number; + totalProjects: number; +}; + +const MEMBERS_FILTERS = ["lead", "members"]; +const DATE_FILTERS = ["created_at"]; + +export const ProjectAppliedFiltersList: React.FC = (props) => { + const { + appliedFilters, + handleClearAllFilters, + handleRemoveFilter, + alwaysAllowEditing, + filteredProjects, + totalProjects, + } = props; + + if (!appliedFilters) return null; + if (Object.keys(appliedFilters).length === 0) return null; + + const isEditingAllowed = alwaysAllowEditing; + + return ( +
+
+ {Object.entries(appliedFilters).map(([key, value]) => { + const filterKey = key as keyof TProjectFilters; + + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + + return ( +
+
+ {replaceUnderscoreIfSnakeCase(filterKey)} + {filterKey === "access" && ( + handleRemoveFilter("access", val)} + values={value} + /> + )} + {DATE_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {MEMBERS_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {isEditingAllowed && ( + + )} +
+
+ ); + })} + {isEditingAllowed && ( + + )} +
+ + {filteredProjects} of{" "} + {totalProjects} projects match the applied filters. +

+ } + > + + {filteredProjects}/{totalProjects} + +
+
+ ); +}; diff --git a/web/components/project/card-list.tsx b/web/components/project/card-list.tsx index df63dfb73..3f23ed9a2 100644 --- a/web/components/project/card-list.tsx +++ b/web/components/project/card-list.tsx @@ -1,10 +1,14 @@ +import Image from "next/image"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication, useEventTracker, useProject } from "hooks/store"; +import { useApplication, useEventTracker, useProject, useProjectFilter } from "hooks/store"; // components import { EmptyState } from "components/empty-state"; import { ProjectCard } from "components/project"; import { ProjectsLoader } from "components/ui"; +// assets +import AllFiltersImage from "public/empty-state/project/all-filters.svg"; +import NameFilterImage from "public/empty-state/project/name-filter.svg"; // constants import { EmptyStateType } from "constants/empty-state"; @@ -12,38 +16,49 @@ export const ProjectCardList = observer(() => { // store hooks const { commandPalette: commandPaletteStore } = useApplication(); const { setTrackElement } = useEventTracker(); + const { workspaceProjectIds, filteredProjectIds, getProjectById } = useProject(); + const { searchQuery } = useProjectFilter(); - const { workspaceProjectIds, searchedProjects, getProjectById } = useProject(); + if (!filteredProjectIds) return ; - if (!workspaceProjectIds) return ; + if (workspaceProjectIds?.length === 0) + return ( + { + setTrackElement("Project empty state"); + commandPaletteStore.toggleCreateProjectModal(true); + }} + /> + ); + if (filteredProjectIds.length === 0) + return ( +
+
+ No matching projects +
No matching projects
+

+ {searchQuery.trim() === "" + ? "Remove the filters to see all projects" + : "No projects detected with the matching\ncriteria. Create a new project instead"} +

+
+
+ ); return ( - <> - {workspaceProjectIds.length > 0 ? ( -
- {searchedProjects.length == 0 ? ( -
No matching projects
- ) : ( -
- {searchedProjects.map((projectId) => { - const projectDetails = getProjectById(projectId); - - if (!projectDetails) return; - - return ; - })} -
- )} -
- ) : ( - { - setTrackElement("Project empty state"); - commandPaletteStore.toggleCreateProjectModal(true); - }} - /> - )} - +
+
+ {filteredProjectIds.map((projectId) => { + const projectDetails = getProjectById(projectId); + if (!projectDetails) return; + return ; + })} +
+
); }); diff --git a/web/components/project/card.tsx b/web/components/project/card.tsx index 08aec43fa..d7562a58f 100644 --- a/web/components/project/card.tsx +++ b/web/components/project/card.tsx @@ -2,36 +2,38 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; import Link from "next/link"; import { useRouter } from "next/router"; -import { LinkIcon, Lock, Pencil, Star } from "lucide-react"; +import { Check, LinkIcon, Lock, Pencil, Star } from "lucide-react"; // ui import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui"; // components import { DeleteProjectModal, JoinProjectModal, ProjectLogo } from "components/project"; // helpers -import { copyTextToClipboard } from "helpers/string.helper"; +import { copyUrlToClipboard } from "helpers/string.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; // hooks import { useProject } from "hooks/store"; // types import type { IProject } from "@plane/types"; -import { EUserProjectRoles } from "constants/project"; // constants +import { EUserProjectRoles } from "constants/project"; -export type ProjectCardProps = { +type Props = { project: IProject; }; -export const ProjectCard: React.FC = observer((props) => { +export const ProjectCard: React.FC = observer((props) => { const { project } = props; - // router - const router = useRouter(); - const { workspaceSlug } = router.query; // states const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false); const [joinProjectModalOpen, setJoinProjectModal] = useState(false); + // router + const router = useRouter(); + const { workspaceSlug } = router.query; // store hooks const { addProjectToFavorites, removeProjectFromFavorites } = useProject(); - - project.member_role; + // derived values + const projectMembersIds = project.members?.map((member) => member.member_id); + // auth const isOwner = project.member_role === EUserProjectRoles.ADMIN; const isMember = project.member_role === EUserProjectRoles.MEMBER; @@ -53,7 +55,7 @@ export const ProjectCard: React.FC = observer((props) => { }; const handleRemoveFromFavorites = () => { - if (!workspaceSlug || !project) return; + if (!workspaceSlug) return; const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id); setPromiseToast(removeFromFavoritePromise, { @@ -69,23 +71,18 @@ export const ProjectCard: React.FC = observer((props) => { }); }; - const handleCopyText = () => { - const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${project.id}/issues`).then(() => { + const handleCopyText = () => + copyUrlToClipboard(`${workspaceSlug}/projects/${project.id}/issues`).then(() => setToast({ type: TOAST_TYPE.SUCCESS, title: "Link Copied!", message: "Project link copied to clipboard.", - }); - }); - }; - - const projectMembersIds = project.members?.map((member) => member.member_id); + }) + ); return ( <> - {/* Delete Project Modal */} + {/* Delete Project Modal */} = observer((props) => { {/* Join Project Modal */} {workspaceSlug && ( setJoinProjectModal(false)} /> )} - - {/* Card Information */} -
{ - if (project.is_member) router.push(`/${workspaceSlug?.toString()}/projects/${project.id}/issues`); - else setJoinProjectModal(true); + { + if (!project.is_member) { + e.preventDefault(); + e.stopPropagation(); + setJoinProjectModal(true); + } }} - className="flex cursor-pointer flex-col rounded border border-custom-border-200 bg-custom-background-100" + className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100" >
@@ -121,12 +120,10 @@ export const ProjectCard: React.FC = observer((props) => { className="absolute left-0 top-0 h-full w-full rounded-t object-cover" /> -
+
-
- - - +
+
@@ -152,15 +149,10 @@ export const ProjectCard: React.FC = observer((props) => {
-

{project.description}

+

+ {project.description && project.description.trim() !== "" + ? project.description + : `Created on ${renderFormattedDate(project.created_at)}`} +

= observer((props) => { No Member Yet )} - {(isOwner || isMember) && ( - { - e.stopPropagation(); - }} - href={`/${workspaceSlug}/projects/${project.id}/settings`} - > - - - )} - - {!project.is_member ? ( + {project.is_member && + (isOwner || isMember ? ( + { + e.stopPropagation(); + }} + href={`/${workspaceSlug}/projects/${project.id}/settings`} + > + + + ) : ( + + + Joined + + ))} + {!project.is_member && (
- ) : null} + )}
-
+ ); }); diff --git a/web/components/project/dropdowns/filters/access.tsx b/web/components/project/dropdowns/filters/access.tsx new file mode 100644 index 000000000..63303872b --- /dev/null +++ b/web/components/project/dropdowns/filters/access.tsx @@ -0,0 +1,48 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// constants +import { NETWORK_CHOICES } from "constants/project"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterAccess: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + // states + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = NETWORK_CHOICES.filter((a) => a.label.includes(searchQuery.toLowerCase())); + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((access) => ( + handleUpdate(`${access.key}`)} + icon={} + title={access.label} + /> + )) + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/project/dropdowns/filters/created-at.tsx b/web/components/project/dropdowns/filters/created-at.tsx new file mode 100644 index 000000000..3867ab148 --- /dev/null +++ b/web/components/project/dropdowns/filters/created-at.tsx @@ -0,0 +1,64 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { DateFilterModal } from "components/core"; +import { FilterHeader, FilterOption } from "components/issues"; +// constants +import { DATE_BEFORE_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string | string[]) => void; + searchQuery: string; +}; + +export const FilterCreatedDate: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = DATE_BEFORE_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( + <> + {isDateFilterModalOpen && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Created date" + /> + )} + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + setIsDateFilterModalOpen(true)} title="Custom" multiple /> + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/project/dropdowns/filters/index.ts b/web/components/project/dropdowns/filters/index.ts new file mode 100644 index 000000000..c04162e57 --- /dev/null +++ b/web/components/project/dropdowns/filters/index.ts @@ -0,0 +1,5 @@ +export * from "./access"; +export * from "./created-at"; +export * from "./lead"; +export * from "./members"; +export * from "./root"; diff --git a/web/components/project/dropdowns/filters/lead.tsx b/web/components/project/dropdowns/filters/lead.tsx new file mode 100644 index 000000000..02c257b9b --- /dev/null +++ b/web/components/project/dropdowns/filters/lead.tsx @@ -0,0 +1,97 @@ +import { useMemo, useState } from "react"; +import { observer } from "mobx-react-lite"; +import sortBy from "lodash/sortBy"; +// hooks +import { useMember } from "hooks/store"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// ui +import { Avatar, Loader } from "@plane/ui"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + memberIds: string[] | undefined; + searchQuery: string; +}; + +export const FilterLead: React.FC = observer((props: Props) => { + const { appliedFilters, handleUpdate, memberIds, searchQuery } = props; + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + // store hooks + const { getUserDetails } = useMember(); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); + + const handleViewToggle = () => { + if (!sortedOptions) return; + + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {sortedOptions ? ( + sortedOptions.length > 0 ? ( + <> + {sortedOptions.slice(0, itemsToRender).map((memberId) => { + const member = getUserDetails(memberId); + + if (!member) return null; + return ( + handleUpdate(member.id)} + icon={} + title={member.display_name} + /> + ); + })} + {sortedOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/project/dropdowns/filters/members.tsx b/web/components/project/dropdowns/filters/members.tsx new file mode 100644 index 000000000..0d2737227 --- /dev/null +++ b/web/components/project/dropdowns/filters/members.tsx @@ -0,0 +1,97 @@ +import { useMemo, useState } from "react"; +import { observer } from "mobx-react-lite"; +import sortBy from "lodash/sortBy"; +// hooks +import { useMember } from "hooks/store"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// ui +import { Avatar, Loader } from "@plane/ui"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + memberIds: string[] | undefined; + searchQuery: string; +}; + +export const FilterMembers: React.FC = observer((props: Props) => { + const { appliedFilters, handleUpdate, memberIds, searchQuery } = props; + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + // store hooks + const { getUserDetails } = useMember(); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); + + const handleViewToggle = () => { + if (!sortedOptions) return; + + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {sortedOptions ? ( + sortedOptions.length > 0 ? ( + <> + {sortedOptions.slice(0, itemsToRender).map((memberId) => { + const member = getUserDetails(memberId); + + if (!member) return null; + return ( + handleUpdate(member.id)} + icon={} + title={member.display_name} + /> + ); + })} + {sortedOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/project/dropdowns/filters/root.tsx b/web/components/project/dropdowns/filters/root.tsx new file mode 100644 index 000000000..e79fc8418 --- /dev/null +++ b/web/components/project/dropdowns/filters/root.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Search, X } from "lucide-react"; +// components +import { FilterAccess, FilterCreatedDate, FilterLead, FilterMembers } from "components/project"; +// types +import { TProjectDisplayFilters, TProjectFilters } from "@plane/types"; +import { FilterOption } from "components/issues"; + +type Props = { + displayFilters: TProjectDisplayFilters; + filters: TProjectFilters; + handleFiltersUpdate: (key: keyof TProjectFilters, value: string | string[]) => void; + handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial) => void; + memberIds?: string[] | undefined; +}; + +export const ProjectFiltersSelection: React.FC = observer((props) => { + const { displayFilters, filters, handleFiltersUpdate, handleDisplayFiltersUpdate, memberIds } = props; + // states + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + return ( +
+
+
+ + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+
+ + handleDisplayFiltersUpdate({ + my_projects: !displayFilters.my_projects, + }) + } + title="My projects" + /> +
+ + {/* access */} +
+ handleFiltersUpdate("access", val)} + searchQuery={filtersSearchQuery} + /> +
+ + {/* lead */} +
+ handleFiltersUpdate("lead", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> +
+ + {/* members */} +
+ handleFiltersUpdate("members", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> +
+ + {/* created date */} +
+ handleFiltersUpdate("created_at", val)} + searchQuery={filtersSearchQuery} + /> +
+
+
+ ); +}); diff --git a/web/components/project/dropdowns/index.ts b/web/components/project/dropdowns/index.ts new file mode 100644 index 000000000..f6c42552f --- /dev/null +++ b/web/components/project/dropdowns/index.ts @@ -0,0 +1,2 @@ +export * from "./filters"; +export * from "./order-by"; diff --git a/web/components/project/dropdowns/order-by.tsx b/web/components/project/dropdowns/order-by.tsx new file mode 100644 index 000000000..ceb61997e --- /dev/null +++ b/web/components/project/dropdowns/order-by.tsx @@ -0,0 +1,74 @@ +import { ArrowDownWideNarrow, Check, ChevronDown } from "lucide-react"; +// ui +import { CustomMenu, getButtonStyling } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TProjectOrderByOptions } from "@plane/types"; +// constants +import { PROJECT_ORDER_BY_OPTIONS } from "constants/project"; + +type Props = { + onChange: (value: TProjectOrderByOptions) => void; + value: TProjectOrderByOptions | undefined; +}; + +const DISABLED_ORDERING_OPTIONS = ["sort_order"]; + +export const ProjectOrderByDropdown: React.FC = (props) => { + const { onChange, value } = props; + + const orderByDetails = PROJECT_ORDER_BY_OPTIONS.find((option) => value?.includes(option.key)); + + const isDescending = value?.[0] === "-"; + const isOrderingDisabled = !!value && DISABLED_ORDERING_OPTIONS.includes(value); + + return ( + + + {orderByDetails?.label} + +
+ } + placement="bottom-end" + closeOnSelect + > + {PROJECT_ORDER_BY_OPTIONS.map((option) => ( + { + if (isDescending) onChange(`-${option.key}` as TProjectOrderByOptions); + else onChange(option.key); + }} + > + {option.label} + {value?.includes(option.key) && } + + ))} +
+ { + if (isDescending) onChange(value.slice(1) as TProjectOrderByOptions); + }} + disabled={isOrderingDisabled} + > + Ascending + {!isOrderingDisabled && !isDescending && } + + { + if (!isDescending) onChange(`-${value}` as TProjectOrderByOptions); + }} + disabled={isOrderingDisabled} + > + Descending + {!isOrderingDisabled && isDescending && } + + + ); +}; diff --git a/web/components/project/index.ts b/web/components/project/index.ts index 6dedb63d4..db51bc284 100644 --- a/web/components/project/index.ts +++ b/web/components/project/index.ts @@ -1,3 +1,5 @@ +export * from "./applied-filters"; +export * from "./dropdowns"; export * from "./publish-project"; export * from "./settings"; export * from "./card-list"; diff --git a/web/components/views/view-list-item.tsx b/web/components/views/view-list-item.tsx index 29d5bac57..959bbe0dd 100644 --- a/web/components/views/view-list-item.tsx +++ b/web/components/views/view-list-item.tsx @@ -59,6 +59,7 @@ export const ProjectViewListItem: React.FC = observer((props) => { }); }; + // @ts-expect-error key types are not compatible const totalFilters = calculateTotalFilters(view.filters ?? {}); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; diff --git a/web/constants/filters.ts b/web/constants/filters.ts index e4131e451..6ac961bb9 100644 --- a/web/constants/filters.ts +++ b/web/constants/filters.ts @@ -1,4 +1,4 @@ -export const DATE_FILTER_OPTIONS = [ +export const DATE_AFTER_FILTER_OPTIONS = [ { name: "1 week from now", value: "1_weeks;after;fromnow", @@ -16,3 +16,18 @@ export const DATE_FILTER_OPTIONS = [ value: "2_months;after;fromnow", }, ]; + +export const DATE_BEFORE_FILTER_OPTIONS = [ + { + name: "1 week ago", + value: "1_weeks;before;fromnow", + }, + { + name: "2 weeks ago", + value: "2_weeks;before;fromnow", + }, + { + name: "1 month ago", + value: "1_months;before;fromnow", + }, +]; diff --git a/web/constants/project.ts b/web/constants/project.ts index 6073e96be..ba6b2c29c 100644 --- a/web/constants/project.ts +++ b/web/constants/project.ts @@ -3,6 +3,7 @@ import { Globe2, Lock, LucideIcon } from "lucide-react"; import { SettingIcon } from "components/icons"; // types import { Props } from "components/icons/types"; +import { TProjectOrderByOptions } from "@plane/types"; export enum EUserProjectRoles { GUEST = 5, @@ -39,23 +40,6 @@ export const GROUP_CHOICES = { cancelled: "Cancelled", }; -export const MONTHS = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; - -export const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - export const PROJECT_AUTOMATION_MONTHS = [ { label: "1 month", value: 1 }, { label: "3 months", value: 3 }, @@ -156,3 +140,25 @@ export const PROJECT_SETTINGS_LINKS: { Icon: SettingIcon, }, ]; + +export const PROJECT_ORDER_BY_OPTIONS: { + key: TProjectOrderByOptions; + label: string; +}[] = [ + { + key: "sort_order", + label: "Manual", + }, + { + key: "name", + label: "Name", + }, + { + key: "created_at", + label: "Created date", + }, + { + key: "members_length", + label: "Number of members", + }, +]; diff --git a/web/helpers/filter.helper.ts b/web/helpers/filter.helper.ts index 3c34fa9da..d8804cabd 100644 --- a/web/helpers/filter.helper.ts +++ b/web/helpers/filter.helper.ts @@ -1,15 +1,22 @@ -import { differenceInCalendarDays } from "date-fns"; -// types -import { IIssueFilterOptions } from "@plane/types"; +import differenceInCalendarDays from "date-fns/differenceInCalendarDays"; -export const calculateTotalFilters = (filters: IIssueFilterOptions): number => +type TFilters = { + [key: string]: string[] | null; +}; + +/** + * @description calculates the total number of filters applied + * @param {TFilters} filters + * @returns {number} + */ +export const calculateTotalFilters = (filters: TFilters): number => filters && Object.keys(filters).length > 0 ? Object.keys(filters) .map((key) => - filters[key as keyof IIssueFilterOptions] !== null - ? isNaN((filters[key as keyof IIssueFilterOptions] as string[]).length) + filters[key as keyof TFilters] !== null + ? isNaN((filters[key as keyof TFilters] as string[]).length) ? 0 - : (filters[key as keyof IIssueFilterOptions] as string[]).length + : (filters[key as keyof TFilters] as string[]).length : 0 ) .reduce((curr, prev) => curr + prev, 0) @@ -30,6 +37,12 @@ export const satisfiesDateFilter = (date: Date, filter: string): boolean => { } if (from === "fromnow") { + if (operator === "before") { + if (value === "1_weeks") return differenceInCalendarDays(date, new Date()) <= -7; + if (value === "2_weeks") return differenceInCalendarDays(date, new Date()) <= -14; + if (value === "1_months") return differenceInCalendarDays(date, new Date()) <= -30; + } + if (operator === "after") { if (value === "1_weeks") return differenceInCalendarDays(date, new Date()) >= 7; if (value === "2_weeks") return differenceInCalendarDays(date, new Date()) >= 14; diff --git a/web/helpers/project.helper.ts b/web/helpers/project.helper.ts index 441c14a42..ba0d52742 100644 --- a/web/helpers/project.helper.ts +++ b/web/helpers/project.helper.ts @@ -1,4 +1,8 @@ -import { IProject } from "@plane/types"; +import sortBy from "lodash/sortBy"; +// helpers +import { satisfiesDateFilter } from "helpers/filter.helper"; +// types +import { IProject, TProjectDisplayFilters, TProjectFilters, TProjectOrderByOptions } from "@plane/types"; /** * Updates the sort order of the project. @@ -46,3 +50,58 @@ export const orderJoinedProjects = ( export const projectIdentifierSanitizer = (identifier: string): string => identifier.replace(/[^ÇŞĞIİÖÜA-Za-z0-9]/g, ""); + +/** + * @description filters projects based on the filter + * @param {IProject} project + * @param {TProjectFilters} filters + * @param {TProjectDisplayFilters} displayFilters + * @returns {boolean} + */ +export const shouldFilterProject = ( + project: IProject, + displayFilters: TProjectDisplayFilters, + filters: TProjectFilters +): boolean => { + let fallsInFilters = true; + Object.keys(filters).forEach((key) => { + const filterKey = key as keyof TProjectFilters; + if (filterKey === "access" && filters.access && filters.access.length > 0) + fallsInFilters = fallsInFilters && filters.access.includes(`${project.network}`); + if (filterKey === "lead" && filters.lead && filters.lead.length > 0) + fallsInFilters = fallsInFilters && filters.lead.includes(`${project.project_lead}`); + if (filterKey === "members" && filters.members && filters.members.length > 0) { + const memberIds = project.members.map((member) => member.member_id); + fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds.includes(memberId)); + } + if (filterKey === "created_at" && filters.created_at && filters.created_at.length > 0) { + filters.created_at.forEach((dateFilter) => { + fallsInFilters = fallsInFilters && satisfiesDateFilter(new Date(project.created_at), dateFilter); + }); + } + }); + if (displayFilters.my_projects && !project.is_member) fallsInFilters = false; + + return fallsInFilters; +}; + +/** + * @description orders projects based on the orderByKey + * @param {IProject[]} projects + * @param {TProjectOrderByOptions | undefined} orderByKey + * @returns {IProject[]} + */ +export const orderProjects = (projects: IProject[], orderByKey: TProjectOrderByOptions | undefined): IProject[] => { + let orderedProjects: IProject[] = []; + if (projects.length === 0) return orderedProjects; + + if (orderByKey === "sort_order") orderedProjects = sortBy(projects, [(p) => p.sort_order]); + if (orderByKey === "name") orderedProjects = sortBy(projects, [(p) => p.name.toLowerCase()]); + if (orderByKey === "-name") orderedProjects = sortBy(projects, [(p) => p.name.toLowerCase()]).reverse(); + if (orderByKey === "created_at") orderedProjects = sortBy(projects, [(p) => p.created_at]); + if (orderByKey === "-created_at") orderedProjects = sortBy(projects, [(p) => !p.created_at]); + if (orderByKey === "members_length") orderedProjects = sortBy(projects, [(p) => p.members.length]); + if (orderByKey === "-members_length") orderedProjects = sortBy(projects, [(p) => p.members.length]).reverse(); + + return orderedProjects; +}; diff --git a/web/hooks/store/index.ts b/web/hooks/store/index.ts index 3ec5c97bf..f67e5cb3a 100644 --- a/web/hooks/store/index.ts +++ b/web/hooks/store/index.ts @@ -11,6 +11,7 @@ export * from "./use-member"; export * from "./use-mention"; export * from "./use-module"; export * from "./use-page"; +export * from "./use-project-filter"; export * from "./use-project-publish"; export * from "./use-project-state"; export * from "./use-project-view"; diff --git a/web/hooks/store/use-project-filter.ts b/web/hooks/store/use-project-filter.ts new file mode 100644 index 000000000..9aebe55d9 --- /dev/null +++ b/web/hooks/store/use-project-filter.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// mobx store +import { StoreContext } from "contexts/store-context"; +// types +import { IProjectFilterStore } from "store/project/project_filter.store"; + +export const useProjectFilter = (): IProjectFilterStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useProjectFilter must be used within StoreProvider"); + return context.projectRoot.projectFilter; +}; diff --git a/web/pages/[workspaceSlug]/projects/index.tsx b/web/pages/[workspaceSlug]/projects/index.tsx index 158e6577f..e941bd8cb 100644 --- a/web/pages/[workspaceSlug]/projects/index.tsx +++ b/web/pages/[workspaceSlug]/projects/index.tsx @@ -1,25 +1,60 @@ -import { ReactElement } from "react"; +import { ReactElement, useCallback } from "react"; import { observer } from "mobx-react"; // components import { PageHead } from "components/core"; import { ProjectsHeader } from "components/headers"; -import { ProjectCardList } from "components/project"; +import { ProjectAppliedFiltersList, ProjectCardList } from "components/project"; // layouts -import { useWorkspace } from "hooks/store"; +import { useApplication, useProject, useProjectFilter, useWorkspace } from "hooks/store"; import { AppLayout } from "layouts/app-layout"; -// type +// helpers +import { calculateTotalFilters } from "helpers/filter.helper"; +// types import { NextPageWithLayout } from "lib/types"; +import { TProjectFilters } from "@plane/types"; const ProjectsPage: NextPageWithLayout = observer(() => { // store + const { + router: { workspaceSlug }, + } = useApplication(); const { currentWorkspace } = useWorkspace(); + const { workspaceProjectIds, filteredProjectIds } = useProject(); + const { currentWorkspaceFilters, clearAllFilters, updateFilters } = useProjectFilter(); // derived values const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined; + const handleRemoveFilter = useCallback( + (key: keyof TProjectFilters, value: string | null) => { + if (!workspaceSlug) return; + let newValues = currentWorkspaceFilters?.[key] ?? []; + + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(workspaceSlug.toString(), { [key]: newValues }); + }, + [currentWorkspaceFilters, updateFilters, workspaceSlug] + ); + return ( <> - +
+ {calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 && ( +
+ clearAllFilters(`${workspaceSlug}`)} + handleRemoveFilter={handleRemoveFilter} + filteredProjects={filteredProjectIds?.length ?? 0} + totalProjects={workspaceProjectIds?.length ?? 0} + alwaysAllowEditing + /> +
+ )} + +
); }); diff --git a/web/public/empty-state/project/all-filters.svg b/web/public/empty-state/project/all-filters.svg new file mode 100644 index 000000000..0280bcf2d --- /dev/null +++ b/web/public/empty-state/project/all-filters.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/empty-state/project/name-filter.svg b/web/public/empty-state/project/name-filter.svg new file mode 100644 index 000000000..a1e89c9a0 --- /dev/null +++ b/web/public/empty-state/project/name-filter.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/store/project/index.ts b/web/store/project/index.ts index dff0db175..87b0dac19 100644 --- a/web/store/project/index.ts +++ b/web/store/project/index.ts @@ -1,18 +1,22 @@ import { RootStore } from "store/root.store"; import { IProjectPublishStore, ProjectPublishStore } from "./project-publish.store"; import { IProjectStore, ProjectStore } from "./project.store"; +import { IProjectFilterStore, ProjectFilterStore } from "./project_filter.store"; export interface IProjectRootStore { project: IProjectStore; + projectFilter: IProjectFilterStore; publish: IProjectPublishStore; } export class ProjectRootStore { project: IProjectStore; + projectFilter: IProjectFilterStore; publish: IProjectPublishStore; constructor(_root: RootStore) { this.project = new ProjectStore(_root); + this.projectFilter = new ProjectFilterStore(_root); this.publish = new ProjectPublishStore(this); } } diff --git a/web/store/project/project.store.ts b/web/store/project/project.store.ts index 1b9220a2d..4f181ec34 100644 --- a/web/store/project/project.store.ts +++ b/web/store/project/project.store.ts @@ -1,4 +1,3 @@ -import { cloneDeep, update } from "lodash"; import set from "lodash/set"; import sortBy from "lodash/sortBy"; import { observable, action, computed, makeObservable, runInAction } from "mobx"; @@ -8,40 +7,38 @@ import { IssueLabelService, IssueService } from "services/issue"; import { ProjectService, ProjectStateService } from "services/project"; import { IProject } from "@plane/types"; import { RootStore } from "../root.store"; +import { orderProjects, shouldFilterProject } from "helpers/project.helper"; // services export interface IProjectStore { // observables - searchQuery: string; projectMap: { [projectId: string]: IProject; // projectId: project Info }; // computed - searchedProjects: string[]; - workspaceProjectIds: string[] | null; + filteredProjectIds: string[] | undefined; + workspaceProjectIds: string[] | undefined; joinedProjectIds: string[]; favoriteProjectIds: string[]; currentProjectDetails: IProject | undefined; // actions - setSearchQuery: (query: string) => void; getProjectById: (projectId: string) => IProject | null; getProjectIdentifierById: (projectId: string) => string; // fetch actions fetchProjects: (workspaceSlug: string) => Promise; - fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise; + fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise; // favorites actions addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise; removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise; // project-view action updateProjectView: (workspaceSlug: string, projectId: string, viewProps: any) => Promise; // CRUD actions - createProject: (workspaceSlug: string, data: any) => Promise; - updateProject: (workspaceSlug: string, projectId: string, data: Partial) => Promise; + createProject: (workspaceSlug: string, data: Partial) => Promise; + updateProject: (workspaceSlug: string, projectId: string, data: Partial) => Promise; deleteProject: (workspaceSlug: string, projectId: string) => Promise; } export class ProjectStore implements IProjectStore { // observables - searchQuery: string = ""; projectMap: { [projectId: string]: IProject; // projectId: project Info } = {}; @@ -56,16 +53,13 @@ export class ProjectStore implements IProjectStore { constructor(_rootStore: RootStore) { makeObservable(this, { // observables - searchQuery: observable.ref, projectMap: observable, // computed - searchedProjects: computed, + filteredProjectIds: computed, workspaceProjectIds: computed, currentProjectDetails: computed, joinedProjectIds: computed, favoriteProjectIds: computed, - // actions - setSearchQuery: action.bound, // fetch actions fetchProjects: action, fetchProjectDetails: action, @@ -88,17 +82,24 @@ export class ProjectStore implements IProjectStore { } /** - * Returns searched projects based on search query + * @description returns filtered projects based on filters and search query */ - get searchedProjects() { + get filteredProjectIds() { const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; - if (!workspaceDetails) return []; - const workspaceProjects = Object.values(this.projectMap).filter( + const { + currentWorkspaceDisplayFilters: displayFilters, + currentWorkspaceFilters: filters, + searchQuery, + } = this.rootStore.projectRoot.projectFilter; + if (!workspaceDetails || !displayFilters || !filters) return; + let workspaceProjects = Object.values(this.projectMap).filter( (p) => p.workspace === workspaceDetails.id && - (p.name.toLowerCase().includes(this.searchQuery.toLowerCase()) || - p.identifier.toLowerCase().includes(this.searchQuery.toLowerCase())) + (p.name.toLowerCase().includes(searchQuery.toLowerCase()) || + p.identifier.toLowerCase().includes(searchQuery.toLowerCase())) && + shouldFilterProject(p, displayFilters, filters) ); + workspaceProjects = orderProjects(workspaceProjects, displayFilters.order_by); return workspaceProjects.map((p) => p.id); } @@ -107,7 +108,7 @@ export class ProjectStore implements IProjectStore { */ get workspaceProjectIds() { const workspaceDetails = this.rootStore.workspaceRoot.currentWorkspace; - if (!workspaceDetails) return null; + if (!workspaceDetails) return; const workspaceProjects = Object.values(this.projectMap).filter((p) => p.workspace === workspaceDetails.id); const projectIds = workspaceProjects.map((p) => p.id); return projectIds ?? null; @@ -153,14 +154,6 @@ export class ProjectStore implements IProjectStore { return projectIds; } - /** - * Sets search query - * @param query - */ - setSearchQuery = (query: string) => { - this.searchQuery = query; - }; - /** * get Workspace projects using workspace slug * @param workspaceSlug diff --git a/web/store/project/project_filter.store.ts b/web/store/project/project_filter.store.ts new file mode 100644 index 000000000..35eac1f62 --- /dev/null +++ b/web/store/project/project_filter.store.ts @@ -0,0 +1,144 @@ +import { action, computed, observable, makeObservable, runInAction, autorun } from "mobx"; +import { computedFn } from "mobx-utils"; +import set from "lodash/set"; +// types +import { RootStore } from "store/root.store"; +import { TProjectDisplayFilters, TProjectFilters } from "@plane/types"; + +export interface IProjectFilterStore { + // observables + displayFilters: Record; + filters: Record; + searchQuery: string; + // computed + currentWorkspaceDisplayFilters: TProjectDisplayFilters | undefined; + currentWorkspaceFilters: TProjectFilters | undefined; + // computed functions + getDisplayFiltersByWorkspaceSlug: (workspaceSlug: string) => TProjectDisplayFilters | undefined; + getFiltersByWorkspaceSlug: (workspaceSlug: string) => TProjectFilters | undefined; + // actions + updateDisplayFilters: (workspaceSlug: string, displayFilters: TProjectDisplayFilters) => void; + updateFilters: (workspaceSlug: string, filters: TProjectFilters) => void; + updateSearchQuery: (query: string) => void; + clearAllFilters: (workspaceSlug: string) => void; +} + +export class ProjectFilterStore implements IProjectFilterStore { + // observables + displayFilters: Record = {}; + filters: Record = {}; + searchQuery: string = ""; + // root store + rootStore: RootStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observables + displayFilters: observable, + filters: observable, + searchQuery: observable.ref, + // computed + currentWorkspaceDisplayFilters: computed, + currentWorkspaceFilters: computed, + // actions + updateDisplayFilters: action, + updateFilters: action, + updateSearchQuery: action, + clearAllFilters: action, + }); + // root store + this.rootStore = _rootStore; + // initialize display filters of the current workspace + autorun(() => { + const workspaceSlug = this.rootStore.app.router.workspaceSlug; + if (!workspaceSlug) return; + this.initWorkspaceFilters(workspaceSlug); + }); + } + + /** + * @description get display filters of the current workspace + */ + get currentWorkspaceDisplayFilters() { + const workspaceSlug = this.rootStore.app.router.workspaceSlug; + if (!workspaceSlug) return; + return this.displayFilters[workspaceSlug]; + } + + /** + * @description get filters of the current workspace + */ + get currentWorkspaceFilters() { + const workspaceSlug = this.rootStore.app.router.workspaceSlug; + if (!workspaceSlug) return; + return this.filters[workspaceSlug]; + } + + /** + * @description get display filters of a workspace by workspaceSlug + * @param {string} workspaceSlug + */ + getDisplayFiltersByWorkspaceSlug = computedFn((workspaceSlug: string) => this.displayFilters[workspaceSlug]); + + /** + * @description get filters of a workspace by workspaceSlug + * @param {string} workspaceSlug + */ + getFiltersByWorkspaceSlug = computedFn((workspaceSlug: string) => this.filters[workspaceSlug]); + + /** + * @description initialize display filters and filters of a workspace + * @param {string} workspaceSlug + */ + initWorkspaceFilters = (workspaceSlug: string) => { + const displayFilters = this.getDisplayFiltersByWorkspaceSlug(workspaceSlug); + runInAction(() => { + this.displayFilters[workspaceSlug] = { + order_by: displayFilters?.order_by || "created_at", + }; + this.filters[workspaceSlug] = {}; + }); + }; + + /** + * @description update display filters of a workspace + * @param {string} workspaceSlug + * @param {TProjectDisplayFilters} displayFilters + */ + updateDisplayFilters = (workspaceSlug: string, displayFilters: TProjectDisplayFilters) => { + runInAction(() => { + Object.keys(displayFilters).forEach((key) => { + set(this.displayFilters, [workspaceSlug, key], displayFilters[key as keyof TProjectDisplayFilters]); + }); + }); + }; + + /** + * @description update filters of a workspace + * @param {string} workspaceSlug + * @param {TProjectFilters} filters + */ + updateFilters = (workspaceSlug: string, filters: TProjectFilters) => { + runInAction(() => { + Object.keys(filters).forEach((key) => { + set(this.filters, [workspaceSlug, key], filters[key as keyof TProjectFilters]); + }); + }); + }; + + /** + * @description update search query + * @param {string} query + */ + updateSearchQuery = (query: string) => (this.searchQuery = query); + + /** + * @description clear all filters of a workspace + * @param {string} workspaceSlug + */ + clearAllFilters = (workspaceSlug: string) => { + runInAction(() => { + this.filters[workspaceSlug] = {}; + }); + }; +} diff --git a/web/store/root.store.ts b/web/store/root.store.ts index 0390d7ce2..930d9877c 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -62,8 +62,8 @@ export class RootStore { this.label = new LabelStore(this); this.estimate = new EstimateStore(this); this.mention = new MentionStore(this); - this.projectPages = new ProjectPageStore(this); this.dashboard = new DashboardStore(this); + this.projectPages = new ProjectPageStore(this); } resetOnSignout() { @@ -82,7 +82,7 @@ export class RootStore { this.label = new LabelStore(this); this.estimate = new EstimateStore(this); this.mention = new MentionStore(this); - this.projectPages = new ProjectPageStore(this); this.dashboard = new DashboardStore(this); + this.projectPages = new ProjectPageStore(this); } } From b930d986654dd57148cdeb98b82f94b781e8b1a3 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 12 Mar 2024 20:24:21 +0530 Subject: [PATCH 12/13] [WEB-554] feat: modules filtering, searching and ordering (#3947) * feat: modules filtering, searching and ordering implemented * fix: modules ordering * chore: total issues in list endpoint * fix: modules ordering * fix: build errors --------- Co-authored-by: NarayanBavisetti --- apiserver/plane/app/views/cycle/base.py | 1 + apiserver/plane/app/views/module/base.py | 10 ++ packages/types/src/index.d.ts | 2 +- packages/types/src/module/index.ts | 2 + packages/types/src/module/module_filters.d.ts | 32 ++++ packages/types/src/{ => module}/modules.d.ts | 0 web/components/cycles/cycles-view-header.tsx | 7 +- web/components/headers/modules-list.tsx | 170 +++++++++++++++--- .../modules/applied-filters/date.tsx | 56 ++++++ .../modules/applied-filters/index.ts | 4 + .../modules/applied-filters/members.tsx | 46 +++++ .../modules/applied-filters/root.tsx | 88 +++++++++ .../modules/applied-filters/status.tsx | 41 +++++ .../modules/dropdowns/filters/index.ts | 6 + .../modules/dropdowns/filters/lead.tsx | 96 ++++++++++ .../modules/dropdowns/filters/members.tsx | 97 ++++++++++ .../modules/dropdowns/filters/root.tsx | 106 +++++++++++ .../modules/dropdowns/filters/start-date.tsx | 65 +++++++ .../modules/dropdowns/filters/status.tsx | 52 ++++++ .../modules/dropdowns/filters/target-date.tsx | 65 +++++++ web/components/modules/dropdowns/index.ts | 2 + web/components/modules/dropdowns/order-by.tsx | 70 ++++++++ .../gantt-chart/modules-list-layout.tsx | 8 +- web/components/modules/index.ts | 2 + web/components/modules/modules-list-view.tsx | 54 ++++-- web/constants/module.ts | 31 +++- web/helpers/module.helper.ts | 80 +++++++++ web/hooks/store/index.ts | 1 + web/hooks/store/use-module-filter.ts | 11 ++ .../projects/[projectId]/modules/index.tsx | 36 +++- web/public/empty-state/module/all-filters.svg | 45 +++++ web/public/empty-state/module/name-filter.svg | 44 +++++ web/store/module.store.ts | 25 +++ web/store/module_filter.store.ts | 146 +++++++++++++++ web/store/root.store.ts | 4 + 35 files changed, 1454 insertions(+), 51 deletions(-) create mode 100644 packages/types/src/module/index.ts create mode 100644 packages/types/src/module/module_filters.d.ts rename packages/types/src/{ => module}/modules.d.ts (100%) create mode 100644 web/components/modules/applied-filters/date.tsx create mode 100644 web/components/modules/applied-filters/index.ts create mode 100644 web/components/modules/applied-filters/members.tsx create mode 100644 web/components/modules/applied-filters/root.tsx create mode 100644 web/components/modules/applied-filters/status.tsx create mode 100644 web/components/modules/dropdowns/filters/index.ts create mode 100644 web/components/modules/dropdowns/filters/lead.tsx create mode 100644 web/components/modules/dropdowns/filters/members.tsx create mode 100644 web/components/modules/dropdowns/filters/root.tsx create mode 100644 web/components/modules/dropdowns/filters/start-date.tsx create mode 100644 web/components/modules/dropdowns/filters/status.tsx create mode 100644 web/components/modules/dropdowns/filters/target-date.tsx create mode 100644 web/components/modules/dropdowns/index.ts create mode 100644 web/components/modules/dropdowns/order-by.tsx create mode 100644 web/helpers/module.helper.ts create mode 100644 web/hooks/store/use-module-filter.ts create mode 100644 web/public/empty-state/module/all-filters.svg create mode 100644 web/public/empty-state/module/name-filter.svg create mode 100644 web/store/module_filter.store.ts diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 189cdb096..e777a93a6 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -354,6 +354,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "external_id", "progress_snapshot", # meta fields + "total_issues", "is_favorite", "cancelled_issues", "completed_issues", diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index ee9718b59..881730d65 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -79,6 +79,15 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ), ) ) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ), + ) .annotate( completed_issues=Count( "issue_module__issue__state__group", @@ -214,6 +223,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "external_source", "external_id", # computed fields + "total_issues", "is_favorite", "cancelled_issues", "completed_issues", diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 6e6244451..48d0c1448 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -5,7 +5,7 @@ export * from "./dashboard"; export * from "./project"; export * from "./state"; export * from "./issues"; -export * from "./modules"; +export * from "./module"; export * from "./views"; export * from "./integration"; export * from "./pages"; diff --git a/packages/types/src/module/index.ts b/packages/types/src/module/index.ts new file mode 100644 index 000000000..783634662 --- /dev/null +++ b/packages/types/src/module/index.ts @@ -0,0 +1,2 @@ +export * from "./module_filters"; +export * from "./modules"; diff --git a/packages/types/src/module/module_filters.d.ts b/packages/types/src/module/module_filters.d.ts new file mode 100644 index 000000000..10d56c328 --- /dev/null +++ b/packages/types/src/module/module_filters.d.ts @@ -0,0 +1,32 @@ +export type TModuleOrderByOptions = + | "name" + | "-name" + | "progress" + | "-progress" + | "issues_length" + | "-issues_length" + | "target_date" + | "-target_date" + | "created_at" + | "-created_at"; + +export type TModuleLayoutOptions = "list" | "board" | "gantt"; + +export type TModuleDisplayFilters = { + favorites?: boolean; + layout?: TModuleLayoutOptions; + order_by?: TModuleOrderByOptions; +}; + +export type TModuleFilters = { + lead?: string[] | null; + members?: string[] | null; + start_date?: string[] | null; + status?: string[] | null; + target_date?: string[] | null; +}; + +export type TModuleStoredFilters = { + display_filters?: TModuleDisplayFilters; + filters?: TModuleFilters; +}; diff --git a/packages/types/src/modules.d.ts b/packages/types/src/module/modules.d.ts similarity index 100% rename from packages/types/src/modules.d.ts rename to packages/types/src/module/modules.d.ts diff --git a/web/components/cycles/cycles-view-header.tsx b/web/components/cycles/cycles-view-header.tsx index b0feede0e..0223fe8c3 100644 --- a/web/components/cycles/cycles-view-header.tsx +++ b/web/components/cycles/cycles-view-header.tsx @@ -62,7 +62,10 @@ export const CyclesViewHeader: React.FC = observer((props) => { const handleInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); - else setIsSearchOpen(false); + else { + setIsSearchOpen(false); + inputRef.current?.blur(); + } } }; @@ -107,7 +110,7 @@ export const CyclesViewHeader: React.FC = observer((props) => { updateSearchQuery(e.target.value)} diff --git a/web/components/headers/modules-list.tsx b/web/components/headers/modules-list.tsx index a1233ae52..a8b9ef3f6 100644 --- a/web/components/headers/modules-list.tsx +++ b/web/components/headers/modules-list.tsx @@ -1,24 +1,34 @@ +import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -// icons -import { GanttChartSquare, LayoutGrid, List, Plus } from "lucide-react"; -// ui -import { Breadcrumbs, Button, Tooltip, DiceIcon, CustomMenu } from "@plane/ui"; +import { GanttChartSquare, LayoutGrid, List, ListFilter, Plus, Search, X } from "lucide-react"; +// hooks +import { useApplication, useEventTracker, useMember, useModuleFilter, useProject, useUser } from "hooks/store"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { ProjectLogo } from "components/project"; +import { ModuleFiltersSelection, ModuleOrderByDropdown } from "components/modules"; +import { FiltersDropdown } from "components/issues"; +// ui +import { Breadcrumbs, Button, Tooltip, DiceIcon, CustomMenu } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TModuleFilters } from "@plane/types"; // constants import { MODULE_VIEW_LAYOUTS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; -// hooks -import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; -import useLocalStorage from "hooks/use-local-storage"; -import { ProjectLogo } from "components/project"; export const ModulesListHeader: React.FC = observer(() => { + // states + const [isSearchOpen, setIsSearchOpen] = useState(false); + // refs + const inputRef = useRef(null); // router const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; // store hooks const { commandPalette: commandPaletteStore } = useApplication(); const { setTrackElement } = useEventTracker(); @@ -26,11 +36,55 @@ export const ModulesListHeader: React.FC = observer(() => { membership: { currentProjectRole }, } = useUser(); const { currentProjectDetails } = useProject(); + const { + workspace: { workspaceMemberIds }, + } = useMember(); + const { + currentProjectDisplayFilters: displayFilters, + currentProjectFilters: filters, + searchQuery, + updateDisplayFilters, + updateFilters, + updateSearchQuery, + } = useModuleFilter(); + // outside click detector hook + useOutsideClickDetector(inputRef, () => { + if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); + }); - const { storedValue: modulesView, setValue: setModulesView } = useLocalStorage("modules_view", "grid"); + const handleFilters = useCallback( + (key: keyof TModuleFilters, value: string | string[]) => { + if (!projectId) return; + const newValues = filters?.[key] ?? []; + if (Array.isArray(value)) + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + else { + if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(projectId.toString(), { [key]: newValues }); + }, + [filters, projectId, updateFilters] + ); + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else { + setIsSearchOpen(false); + inputRef.current?.blur(); + } + } + }; + + // auth const canUserCreateModule = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + return (
@@ -62,26 +116,97 @@ export const ModulesListHeader: React.FC = observer(() => {
-
+
+ {!isSearchOpen && ( + + )} +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+
+
{MODULE_VIEW_LAYOUTS.map((layout) => ( ))}
+ { + if (!projectId || val === displayFilters?.order_by) return; + updateDisplayFilters(projectId.toString(), { + order_by: val, + }); + }} + /> + } title="Filters" placement="bottom-end"> + { + if (!projectId) return; + updateDisplayFilters(projectId.toString(), val); + }} + handleFiltersUpdate={handleFilters} + memberIds={workspaceMemberIds ?? undefined} + /> + {canUserCreateModule && ( + )} +
+ ))} + + ); +}); diff --git a/web/components/modules/applied-filters/index.ts b/web/components/modules/applied-filters/index.ts new file mode 100644 index 000000000..cf34b6e69 --- /dev/null +++ b/web/components/modules/applied-filters/index.ts @@ -0,0 +1,4 @@ +export * from "./date"; +export * from "./members"; +export * from "./root"; +export * from "./status"; diff --git a/web/components/modules/applied-filters/members.tsx b/web/components/modules/applied-filters/members.tsx new file mode 100644 index 000000000..88f18ee0c --- /dev/null +++ b/web/components/modules/applied-filters/members.tsx @@ -0,0 +1,46 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// ui +import { Avatar } from "@plane/ui"; +// types +import { useMember } from "hooks/store"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedMembersFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + // store hooks + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + + return ( + <> + {values.map((memberId) => { + const memberDetails = getWorkspaceMemberDetails(memberId)?.member; + + if (!memberDetails) return null; + + return ( +
+ + {memberDetails.display_name} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/components/modules/applied-filters/root.tsx b/web/components/modules/applied-filters/root.tsx new file mode 100644 index 000000000..2969ea715 --- /dev/null +++ b/web/components/modules/applied-filters/root.tsx @@ -0,0 +1,88 @@ +import { X } from "lucide-react"; +// components +import { AppliedDateFilters, AppliedMembersFilters, AppliedStatusFilters } from "components/modules"; +// helpers +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +// types +import { TModuleFilters } from "@plane/types"; + +type Props = { + appliedFilters: TModuleFilters; + handleClearAllFilters: () => void; + handleRemoveFilter: (key: keyof TModuleFilters, value: string | null) => void; + alwaysAllowEditing?: boolean; +}; + +const MEMBERS_FILTERS = ["lead", "members"]; +const DATE_FILTERS = ["start_date", "target_date"]; + +export const ModuleAppliedFiltersList: React.FC = (props) => { + const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props; + + if (!appliedFilters) return null; + if (Object.keys(appliedFilters).length === 0) return null; + + const isEditingAllowed = alwaysAllowEditing; + + return ( +
+ {Object.entries(appliedFilters).map(([key, value]) => { + const filterKey = key as keyof TModuleFilters; + + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + + return ( +
+
+ {replaceUnderscoreIfSnakeCase(filterKey)} + {filterKey === "status" && ( + handleRemoveFilter("status", val)} + values={value} + /> + )} + {DATE_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {MEMBERS_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {isEditingAllowed && ( + + )} +
+
+ ); + })} + {isEditingAllowed && ( + + )} +
+ ); +}; diff --git a/web/components/modules/applied-filters/status.tsx b/web/components/modules/applied-filters/status.tsx new file mode 100644 index 000000000..ed5426cde --- /dev/null +++ b/web/components/modules/applied-filters/status.tsx @@ -0,0 +1,41 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// ui +import { ModuleStatusIcon } from "@plane/ui"; +// constants +import { MODULE_STATUS } from "constants/module"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedStatusFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + + return ( + <> + {values.map((status) => { + const statusDetails = MODULE_STATUS?.find((s) => s.value === status); + if (!statusDetails) return null; + + return ( +
+ + {statusDetails.label} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/components/modules/dropdowns/filters/index.ts b/web/components/modules/dropdowns/filters/index.ts new file mode 100644 index 000000000..786fc5cec --- /dev/null +++ b/web/components/modules/dropdowns/filters/index.ts @@ -0,0 +1,6 @@ +export * from "./lead"; +export * from "./members"; +export * from "./root"; +export * from "./start-date"; +export * from "./status"; +export * from "./target-date"; diff --git a/web/components/modules/dropdowns/filters/lead.tsx b/web/components/modules/dropdowns/filters/lead.tsx new file mode 100644 index 000000000..ffd4f8a2e --- /dev/null +++ b/web/components/modules/dropdowns/filters/lead.tsx @@ -0,0 +1,96 @@ +import { useMemo, useState } from "react"; +import { observer } from "mobx-react-lite"; +import sortBy from "lodash/sortBy"; +// hooks +import { useMember } from "hooks/store"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// ui +import { Avatar, Loader } from "@plane/ui"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + memberIds: string[] | undefined; + searchQuery: string; +}; + +export const FilterLead: React.FC = observer((props: Props) => { + const { appliedFilters, handleUpdate, memberIds, searchQuery } = props; + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + // store hooks + const { getUserDetails } = useMember(); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + }, [appliedFilters, getUserDetails, memberIds, , searchQuery]); + + const handleViewToggle = () => { + if (!sortedOptions) return; + + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {sortedOptions ? ( + sortedOptions.length > 0 ? ( + <> + {sortedOptions.slice(0, itemsToRender).map((memberId) => { + const member = getUserDetails(memberId); + + if (!member) return null; + return ( + handleUpdate(member.id)} + icon={} + title={member.display_name} + /> + ); + })} + {sortedOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/modules/dropdowns/filters/members.tsx b/web/components/modules/dropdowns/filters/members.tsx new file mode 100644 index 000000000..0d2737227 --- /dev/null +++ b/web/components/modules/dropdowns/filters/members.tsx @@ -0,0 +1,97 @@ +import { useMemo, useState } from "react"; +import { observer } from "mobx-react-lite"; +import sortBy from "lodash/sortBy"; +// hooks +import { useMember } from "hooks/store"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// ui +import { Avatar, Loader } from "@plane/ui"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + memberIds: string[] | undefined; + searchQuery: string; +}; + +export const FilterMembers: React.FC = observer((props: Props) => { + const { appliedFilters, handleUpdate, memberIds, searchQuery } = props; + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + // store hooks + const { getUserDetails } = useMember(); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); + + const handleViewToggle = () => { + if (!sortedOptions) return; + + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {sortedOptions ? ( + sortedOptions.length > 0 ? ( + <> + {sortedOptions.slice(0, itemsToRender).map((memberId) => { + const member = getUserDetails(memberId); + + if (!member) return null; + return ( + handleUpdate(member.id)} + icon={} + title={member.display_name} + /> + ); + })} + {sortedOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/modules/dropdowns/filters/root.tsx b/web/components/modules/dropdowns/filters/root.tsx new file mode 100644 index 000000000..30841a43a --- /dev/null +++ b/web/components/modules/dropdowns/filters/root.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Search, X } from "lucide-react"; +// components +import { FilterLead, FilterMembers, FilterStartDate, FilterStatus, FilterTargetDate } from "components/modules"; +import { FilterOption } from "components/issues"; +// types +import { TModuleDisplayFilters, TModuleFilters } from "@plane/types"; +import { TModuleStatus } from "@plane/ui"; + +type Props = { + displayFilters: TModuleDisplayFilters; + filters: TModuleFilters; + handleDisplayFiltersUpdate: (updatedDisplayProperties: Partial) => void; + handleFiltersUpdate: (key: keyof TModuleFilters, value: string | string[]) => void; + memberIds?: string[] | undefined; +}; + +export const ModuleFiltersSelection: React.FC = observer((props) => { + const { displayFilters, filters, handleDisplayFiltersUpdate, handleFiltersUpdate, memberIds } = props; + // states + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + return ( +
+
+
+ + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+
+ + handleDisplayFiltersUpdate({ + favorites: !displayFilters.favorites, + }) + } + title="Favorites" + /> +
+ + {/* status */} +
+ handleFiltersUpdate("status", val)} + searchQuery={filtersSearchQuery} + /> +
+ + {/* lead */} +
+ handleFiltersUpdate("lead", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> +
+ + {/* members */} +
+ handleFiltersUpdate("members", val)} + searchQuery={filtersSearchQuery} + memberIds={memberIds} + /> +
+ + {/* start date */} +
+ handleFiltersUpdate("start_date", val)} + searchQuery={filtersSearchQuery} + /> +
+ + {/* target date */} +
+ handleFiltersUpdate("target_date", val)} + searchQuery={filtersSearchQuery} + /> +
+
+
+ ); +}); diff --git a/web/components/modules/dropdowns/filters/start-date.tsx b/web/components/modules/dropdowns/filters/start-date.tsx new file mode 100644 index 000000000..3c47eb286 --- /dev/null +++ b/web/components/modules/dropdowns/filters/start-date.tsx @@ -0,0 +1,65 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; + +// components +import { DateFilterModal } from "components/core"; +import { FilterHeader, FilterOption } from "components/issues"; +// constants +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string | string[]) => void; + searchQuery: string; +}; + +export const FilterStartDate: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( + <> + {isDateFilterModalOpen && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Start date" + /> + )} + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + setIsDateFilterModalOpen(true)} title="Custom" multiple /> + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/modules/dropdowns/filters/status.tsx b/web/components/modules/dropdowns/filters/status.tsx new file mode 100644 index 000000000..f73db2554 --- /dev/null +++ b/web/components/modules/dropdowns/filters/status.tsx @@ -0,0 +1,52 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// ui +import { ModuleStatusIcon } from "@plane/ui"; +// types +import { TModuleStatus } from "@plane/types"; +// constants +import { MODULE_STATUS } from "constants/module"; + +type Props = { + appliedFilters: TModuleStatus[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterStatus: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + // states + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = MODULE_STATUS.filter((p) => p.value.includes(searchQuery.toLowerCase())); + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((status) => ( + handleUpdate(status.value)} + icon={} + title={status.label} + /> + )) + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/modules/dropdowns/filters/target-date.tsx b/web/components/modules/dropdowns/filters/target-date.tsx new file mode 100644 index 000000000..d563dbe92 --- /dev/null +++ b/web/components/modules/dropdowns/filters/target-date.tsx @@ -0,0 +1,65 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; + +// components +import { DateFilterModal } from "components/core"; +import { FilterHeader, FilterOption } from "components/issues"; +// constants +import { DATE_AFTER_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string | string[]) => void; + searchQuery: string; +}; + +export const FilterTargetDate: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) => + d.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( + <> + {isDateFilterModalOpen && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Due date" + /> + )} + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + setIsDateFilterModalOpen(true)} title="Custom" multiple /> + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/modules/dropdowns/index.ts b/web/components/modules/dropdowns/index.ts new file mode 100644 index 000000000..f6c42552f --- /dev/null +++ b/web/components/modules/dropdowns/index.ts @@ -0,0 +1,2 @@ +export * from "./filters"; +export * from "./order-by"; diff --git a/web/components/modules/dropdowns/order-by.tsx b/web/components/modules/dropdowns/order-by.tsx new file mode 100644 index 000000000..a611d1ead --- /dev/null +++ b/web/components/modules/dropdowns/order-by.tsx @@ -0,0 +1,70 @@ +import { ArrowDownWideNarrow, Check, ChevronDown } from "lucide-react"; +// ui +import { CustomMenu, getButtonStyling } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TModuleOrderByOptions } from "@plane/types"; +// constants +import { MODULE_ORDER_BY_OPTIONS } from "constants/module"; + +type Props = { + onChange: (value: TModuleOrderByOptions) => void; + value: TModuleOrderByOptions | undefined; +}; + +export const ModuleOrderByDropdown: React.FC = (props) => { + const { onChange, value } = props; + + const orderByDetails = MODULE_ORDER_BY_OPTIONS.find((option) => value?.includes(option.key)); + + const isDescending = value?.[0] === "-"; + + return ( + + + {orderByDetails?.label} + +
+ } + placement="bottom-end" + maxHeight="lg" + closeOnSelect + > + {MODULE_ORDER_BY_OPTIONS.map((option) => ( + { + if (isDescending) onChange(`-${option.key}` as TModuleOrderByOptions); + else onChange(option.key); + }} + > + {option.label} + {value?.includes(option.key) && } + + ))} +
+ { + if (isDescending) onChange(value.slice(1) as TModuleOrderByOptions); + }} + > + Ascending + {!isDescending && } + + { + if (!isDescending) onChange(`-${value}` as TModuleOrderByOptions); + }} + > + Descending + {isDescending && } + + + ); +}; diff --git a/web/components/modules/gantt-chart/modules-list-layout.tsx b/web/components/modules/gantt-chart/modules-list-layout.tsx index 0a9b433c5..6c680e1ca 100644 --- a/web/components/modules/gantt-chart/modules-list-layout.tsx +++ b/web/components/modules/gantt-chart/modules-list-layout.tsx @@ -11,10 +11,12 @@ import { IModule } from "@plane/types"; export const ModulesListGanttChartView: React.FC = observer(() => { // router const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; // store const { currentProjectDetails } = useProject(); - const { projectModuleIds, moduleMap, updateModuleDetails } = useModule(); + const { getFilteredModuleIds, moduleMap, updateModuleDetails } = useModule(); + // derived values + const filteredModuleIds = projectId ? getFilteredModuleIds(projectId.toString()) : undefined; const handleModuleUpdate = async (module: IModule, data: IBlockUpdateData) => { if (!workspaceSlug || !module) return; @@ -44,7 +46,7 @@ export const ModulesListGanttChartView: React.FC = observer(() => { } blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)} blockToRender={(data: IModule) => } diff --git a/web/components/modules/index.ts b/web/components/modules/index.ts index c87ea79d2..7bda973fa 100644 --- a/web/components/modules/index.ts +++ b/web/components/modules/index.ts @@ -1,3 +1,5 @@ +export * from "./applied-filters"; +export * from "./dropdowns"; export * from "./select"; export * from "./sidebar-select"; export * from "./delete-module-modal"; diff --git a/web/components/modules/modules-list-view.tsx b/web/components/modules/modules-list-view.tsx index 78b4a6571..2998843e1 100644 --- a/web/components/modules/modules-list-view.tsx +++ b/web/components/modules/modules-list-view.tsx @@ -1,13 +1,16 @@ +import Image from "next/image"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // hooks -import { useApplication, useEventTracker, useModule } from "hooks/store"; -import useLocalStorage from "hooks/use-local-storage"; +import { useApplication, useEventTracker, useModule, useModuleFilter } from "hooks/store"; // components import { ModuleCardItem, ModuleListItem, ModulePeekOverview, ModulesListGanttChartView } from "components/modules"; import { EmptyState } from "components/empty-state"; // ui import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui"; +// assets +import NameFilterImage from "public/empty-state/module/name-filter.svg"; +import AllFiltersImage from "public/empty-state/module/all-filters.svg"; // constants import { EmptyStateType } from "constants/empty-state"; @@ -18,29 +21,48 @@ export const ModulesListView: React.FC = observer(() => { // store hooks const { commandPalette: commandPaletteStore } = useApplication(); const { setTrackElement } = useEventTracker(); + const { getFilteredModuleIds, loader } = useModule(); + const { currentProjectDisplayFilters: displayFilters, searchQuery } = useModuleFilter(); + // derived values + const filteredModuleIds = projectId ? getFilteredModuleIds(projectId.toString()) : undefined; - const { projectModuleIds, loader } = useModule(); - - const { storedValue: modulesView } = useLocalStorage("modules_view", "grid"); - - if (loader || !projectModuleIds) + if (loader || !filteredModuleIds) return ( <> - {modulesView === "list" && } - {modulesView === "grid" && } - {modulesView === "gantt_chart" && } + {displayFilters?.layout === "list" && } + {displayFilters?.layout === "board" && } + {displayFilters?.layout === "gantt" && } ); + if (filteredModuleIds.length === 0) + return ( +
+
+ No matching modules +
No matching modules
+

+ {searchQuery.trim() === "" + ? "Remove the filters to see all modules" + : "Remove the search criteria to see all modules"} +

+
+
+ ); + return ( <> - {projectModuleIds.length > 0 ? ( + {filteredModuleIds.length > 0 ? ( <> - {modulesView === "list" && ( + {displayFilters?.layout === "list" && (
- {projectModuleIds.map((moduleId) => ( + {filteredModuleIds.map((moduleId) => ( ))}
@@ -51,7 +73,7 @@ export const ModulesListView: React.FC = observer(() => {
)} - {modulesView === "grid" && ( + {displayFilters?.layout === "board" && (
{ : "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" } auto-rows-max transition-all vertical-scrollbar scrollbar-lg`} > - {projectModuleIds.map((moduleId) => ( + {filteredModuleIds.map((moduleId) => ( ))}
@@ -72,7 +94,7 @@ export const ModulesListView: React.FC = observer(() => {
)} - {modulesView === "gantt_chart" && } + {displayFilters?.layout === "gantt" && } ) : ( { + let orderedModules: IModule[] = []; + if (modules.length === 0 || !orderByKey) return []; + + if (orderByKey === "name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]); + if (orderByKey === "-name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]).reverse(); + if (["progress", "-progress"].includes(orderByKey)) + orderedModules = sortBy(modules, [ + (m) => { + let progress = (m.completed_issues + m.cancelled_issues) / m.total_issues; + if (isNaN(progress)) progress = 0; + return orderByKey === "progress" ? progress : !progress; + }, + "name", + ]); + if (["issues_length", "-issues_length"].includes(orderByKey)) + orderedModules = sortBy(modules, [ + (m) => (orderByKey === "issues_length" ? m.total_issues : !m.total_issues), + "name", + ]); + if (orderByKey === "target_date") orderedModules = sortBy(modules, [(m) => m.target_date]); + if (orderByKey === "-target_date") orderedModules = sortBy(modules, [(m) => !m.target_date]); + if (orderByKey === "created_at") orderedModules = sortBy(modules, [(m) => m.created_at]); + if (orderByKey === "-created_at") orderedModules = sortBy(modules, [(m) => !m.created_at]); + + return orderedModules; +}; + +/** + * @description filters modules based on the filters + * @param {IModule} module + * @param {TModuleDisplayFilters} displayFilters + * @param {TModuleFilters} filters + * @returns {boolean} + */ +export const shouldFilterModule = ( + module: IModule, + displayFilters: TModuleDisplayFilters, + filters: TModuleFilters +): boolean => { + let fallsInFilters = true; + Object.keys(filters).forEach((key) => { + const filterKey = key as keyof TModuleFilters; + if (filterKey === "status" && filters.status && filters.status.length > 0) + fallsInFilters = fallsInFilters && filters.status.includes(module.status.toLowerCase()); + if (filterKey === "lead" && filters.lead && filters.lead.length > 0) + fallsInFilters = fallsInFilters && filters.lead.includes(`${module.lead_id}`); + if (filterKey === "members" && filters.members && filters.members.length > 0) { + const memberIds = module.member_ids; + fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds.includes(memberId)); + } + if (filterKey === "start_date" && filters.start_date && filters.start_date.length > 0) { + filters.start_date.forEach((dateFilter) => { + fallsInFilters = + fallsInFilters && !!module.start_date && satisfiesDateFilter(new Date(module.start_date), dateFilter); + }); + } + if (filterKey === "target_date" && filters.target_date && filters.target_date.length > 0) { + filters.target_date.forEach((dateFilter) => { + fallsInFilters = + fallsInFilters && !!module.target_date && satisfiesDateFilter(new Date(module.target_date), dateFilter); + }); + } + }); + if (displayFilters.favorites && !module.is_favorite) fallsInFilters = false; + + return fallsInFilters; +}; diff --git a/web/hooks/store/index.ts b/web/hooks/store/index.ts index f67e5cb3a..906b473ff 100644 --- a/web/hooks/store/index.ts +++ b/web/hooks/store/index.ts @@ -10,6 +10,7 @@ export * from "./use-label"; export * from "./use-member"; export * from "./use-mention"; export * from "./use-module"; +export * from "./use-module-filter"; export * from "./use-page"; export * from "./use-project-filter"; export * from "./use-project-publish"; diff --git a/web/hooks/store/use-module-filter.ts b/web/hooks/store/use-module-filter.ts new file mode 100644 index 000000000..783cea6d6 --- /dev/null +++ b/web/hooks/store/use-module-filter.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// mobx store +import { StoreContext } from "contexts/store-context"; +// types +import { IModuleFilterStore } from "store/module_filter.store"; + +export const useModuleFilter = (): IModuleFilterStore => { + const context = useContext(StoreContext); + if (context === undefined) throw new Error("useModuleFilter must be used within StoreProvider"); + return context.moduleFilter; +}; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx index 3648f5922..eb3c92044 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/modules/index.tsx @@ -1,30 +1,58 @@ -import { ReactElement } from "react"; +import { ReactElement, useCallback } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // layouts // components import { PageHead } from "components/core"; import { ModulesListHeader } from "components/headers"; -import { ModulesListView } from "components/modules"; +import { ModuleAppliedFiltersList, ModulesListView } from "components/modules"; // types // hooks -import { useProject } from "hooks/store"; +import { useModuleFilter, useProject } from "hooks/store"; import { AppLayout } from "layouts/app-layout"; import { NextPageWithLayout } from "lib/types"; +import { calculateTotalFilters } from "helpers/filter.helper"; +import { TModuleFilters } from "@plane/types"; const ProjectModulesPage: NextPageWithLayout = observer(() => { const router = useRouter(); const { projectId } = router.query; // store const { getProjectById } = useProject(); + const { currentProjectFilters, clearAllFilters, updateFilters } = useModuleFilter(); // derived values const project = projectId ? getProjectById(projectId.toString()) : undefined; const pageTitle = project?.name ? `${project?.name} - Modules` : undefined; + const handleRemoveFilter = useCallback( + (key: keyof TModuleFilters, value: string | null) => { + if (!projectId) return; + let newValues = currentProjectFilters?.[key] ?? []; + + if (!value) newValues = []; + else newValues = newValues.filter((val) => val !== value); + + updateFilters(projectId.toString(), { [key]: newValues }); + }, + [currentProjectFilters, projectId, updateFilters] + ); + return ( <> - +
+ {calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && ( +
+ clearAllFilters(`${projectId}`)} + handleRemoveFilter={handleRemoveFilter} + alwaysAllowEditing + /> +
+ )} + +
); }); diff --git a/web/public/empty-state/module/all-filters.svg b/web/public/empty-state/module/all-filters.svg new file mode 100644 index 000000000..6ba0731fe --- /dev/null +++ b/web/public/empty-state/module/all-filters.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/public/empty-state/module/name-filter.svg b/web/public/empty-state/module/name-filter.svg new file mode 100644 index 000000000..0d9655b66 --- /dev/null +++ b/web/public/empty-state/module/name-filter.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/store/module.store.ts b/web/store/module.store.ts index c7dcba79c..8b589a66f 100644 --- a/web/store/module.store.ts +++ b/web/store/module.store.ts @@ -5,6 +5,8 @@ import { computedFn } from "mobx-utils"; // services import { ModuleService } from "services/module.service"; import { ProjectService } from "services/project"; +// helpers +import { orderModules, shouldFilterModule } from "helpers/module.helper"; // types import { RootStore } from "store/root.store"; import { IModule, ILinkDetails } from "@plane/types"; @@ -18,6 +20,7 @@ export interface IModuleStore { // computed projectModuleIds: string[] | null; // computed actions + getFilteredModuleIds: (projectId: string) => string[] | null; getModuleById: (moduleId: string) => IModule | null; getModuleNameById: (moduleId: string) => string; getProjectModuleIds: (projectId: string) => string[] | null; @@ -108,6 +111,28 @@ export class ModulesStore implements IModuleStore { return projectModuleIds || null; } + /** + * @description returns filtered module ids based on display filters and filters + * @param {TModuleDisplayFilters} displayFilters + * @param {TModuleFilters} filters + * @returns {string[] | null} + */ + getFilteredModuleIds = computedFn((projectId: string) => { + const displayFilters = this.rootStore.moduleFilter.getDisplayFiltersByProjectId(projectId); + const filters = this.rootStore.moduleFilter.getFiltersByProjectId(projectId); + const searchQuery = this.rootStore.moduleFilter.searchQuery; + if (!this.fetchedMap[projectId]) return null; + let modules = Object.values(this.moduleMap ?? {}).filter( + (m) => + m.project_id === projectId && + m.name.toLowerCase().includes(searchQuery.toLowerCase()) && + shouldFilterModule(m, displayFilters ?? {}, filters ?? {}) + ); + modules = orderModules(modules, displayFilters?.order_by); + const moduleIds = modules.map((m) => m.id); + return moduleIds; + }); + /** * @description get module by id * @param moduleId diff --git a/web/store/module_filter.store.ts b/web/store/module_filter.store.ts new file mode 100644 index 000000000..52f8f1d4f --- /dev/null +++ b/web/store/module_filter.store.ts @@ -0,0 +1,146 @@ +import { action, computed, observable, makeObservable, runInAction, autorun } from "mobx"; +import { computedFn } from "mobx-utils"; +import set from "lodash/set"; +// types +import { RootStore } from "store/root.store"; +import { TModuleDisplayFilters, TModuleFilters } from "@plane/types"; + +export interface IModuleFilterStore { + // observables + displayFilters: Record; + filters: Record; + searchQuery: string; + // computed + currentProjectDisplayFilters: TModuleDisplayFilters | undefined; + currentProjectFilters: TModuleFilters | undefined; + // computed functions + getDisplayFiltersByProjectId: (projectId: string) => TModuleDisplayFilters | undefined; + getFiltersByProjectId: (projectId: string) => TModuleFilters | undefined; + // actions + updateDisplayFilters: (projectId: string, displayFilters: TModuleDisplayFilters) => void; + updateFilters: (projectId: string, filters: TModuleFilters) => void; + updateSearchQuery: (query: string) => void; + clearAllFilters: (projectId: string) => void; +} + +export class ModuleFilterStore implements IModuleFilterStore { + // observables + displayFilters: Record = {}; + filters: Record = {}; + searchQuery: string = ""; + // root store + rootStore: RootStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observables + displayFilters: observable, + filters: observable, + searchQuery: observable.ref, + // computed + currentProjectDisplayFilters: computed, + currentProjectFilters: computed, + // actions + updateDisplayFilters: action, + updateFilters: action, + updateSearchQuery: action, + clearAllFilters: action, + }); + // root store + this.rootStore = _rootStore; + // initialize display filters of the current project + autorun(() => { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + this.initProjectModuleFilters(projectId); + }); + } + + /** + * @description get display filters of the current project + */ + get currentProjectDisplayFilters() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + return this.displayFilters[projectId]; + } + + /** + * @description get filters of the current project + */ + get currentProjectFilters() { + const projectId = this.rootStore.app.router.projectId; + if (!projectId) return; + return this.filters[projectId]; + } + + /** + * @description get display filters of a project by projectId + * @param {string} projectId + */ + getDisplayFiltersByProjectId = computedFn((projectId: string) => this.displayFilters[projectId]); + + /** + * @description get filters of a project by projectId + * @param {string} projectId + */ + getFiltersByProjectId = computedFn((projectId: string) => this.filters[projectId]); + + /** + * @description initialize display filters and filters of a project + * @param {string} projectId + */ + initProjectModuleFilters = (projectId: string) => { + const displayFilters = this.getDisplayFiltersByProjectId(projectId); + runInAction(() => { + this.displayFilters[projectId] = { + favorites: displayFilters?.favorites || false, + layout: displayFilters?.layout || "list", + order_by: displayFilters?.order_by || "name", + }; + this.filters[projectId] = {}; + }); + }; + + /** + * @description update display filters of a project + * @param {string} projectId + * @param {TModuleDisplayFilters} displayFilters + */ + updateDisplayFilters = (projectId: string, displayFilters: TModuleDisplayFilters) => { + runInAction(() => { + Object.keys(displayFilters).forEach((key) => { + set(this.displayFilters, [projectId, key], displayFilters[key as keyof TModuleDisplayFilters]); + }); + }); + }; + + /** + * @description update filters of a project + * @param {string} projectId + * @param {TModuleFilters} filters + */ + updateFilters = (projectId: string, filters: TModuleFilters) => { + runInAction(() => { + Object.keys(filters).forEach((key) => { + set(this.filters, [projectId, key], filters[key as keyof TModuleFilters]); + }); + }); + }; + + /** + * @description update search query + * @param {string} query + */ + updateSearchQuery = (query: string) => (this.searchQuery = query); + + /** + * @description clear all filters of a project + * @param {string} projectId + */ + clearAllFilters = (projectId: string) => { + runInAction(() => { + this.filters[projectId] = {}; + }); + }; +} diff --git a/web/store/root.store.ts b/web/store/root.store.ts index 930d9877c..5dc3c2574 100644 --- a/web/store/root.store.ts +++ b/web/store/root.store.ts @@ -19,6 +19,7 @@ import { IUserRootStore, UserRootStore } from "./user"; import { IWorkspaceRootStore, WorkspaceRootStore } from "./workspace"; import { IProjectPageStore, ProjectPageStore } from "./project-page.store"; import { CycleFilterStore, ICycleFilterStore } from "./cycle_filter.store"; +import { IModuleFilterStore, ModuleFilterStore } from "./module_filter.store"; enableStaticRendering(typeof window === "undefined"); @@ -32,6 +33,7 @@ export class RootStore { cycle: ICycleStore; cycleFilter: ICycleFilterStore; module: IModuleStore; + moduleFilter: IModuleFilterStore; projectView: IProjectViewStore; globalView: IGlobalViewStore; issue: IIssueRootStore; @@ -54,6 +56,7 @@ export class RootStore { this.cycle = new CycleStore(this); this.cycleFilter = new CycleFilterStore(this); this.module = new ModulesStore(this); + this.moduleFilter = new ModuleFilterStore(this); this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this); this.issue = new IssueRootStore(this); @@ -74,6 +77,7 @@ export class RootStore { this.cycle = new CycleStore(this); this.cycleFilter = new CycleFilterStore(this); this.module = new ModulesStore(this); + this.moduleFilter = new ModuleFilterStore(this); this.projectView = new ProjectViewStore(this); this.globalView = new GlobalViewStore(this); this.issue = new IssueRootStore(this); From cb632408f9e4806d27edbd28b1761661b4dd8a3e Mon Sep 17 00:00:00 2001 From: Ramesh Kumar Chandra <31303617+rameshkumarchandra@users.noreply.github.com> Date: Tue, 12 Mar 2024 20:39:36 +0530 Subject: [PATCH 13/13] [WEB-713] style: remove tooltips in mobile responsiveness (#3948) Co-authored-by: sriram veeraghanta --- packages/ui/src/tooltip/tooltip.tsx | 4 +++- .../modal/generated-token-details.tsx | 6 ++++-- web/components/api-token/token-list-item.tsx | 10 ++++++---- .../command-palette/command-modal.tsx | 6 +++--- web/components/common/breadcrumb-link.tsx | 4 +++- web/components/core/activity.tsx | 7 ++++++- .../core/modals/existing-issues-list-modal.tsx | 6 +++--- web/components/core/sidebar/links-list.tsx | 5 +++-- web/components/cycles/active-cycle/root.tsx | 10 +++++++--- .../cycles/board/cycles-board-card.tsx | 8 ++++++-- web/components/cycles/cycles-view-header.tsx | 4 +++- web/components/cycles/gantt-chart/blocks.tsx | 4 +++- .../cycles/list/cycles-list-item.tsx | 7 +++++-- web/components/dropdowns/buttons.tsx | 14 +++++++------- web/components/dropdowns/module/index.tsx | 6 ++++-- web/components/dropdowns/priority.tsx | 13 ++++++++++--- .../gantt-chart/helpers/add-block.tsx | 6 +++++- web/components/headers/cycle-issues.tsx | 6 +++++- web/components/headers/global-issues.tsx | 4 +++- web/components/headers/module-issues.tsx | 4 ++++ web/components/headers/modules-list.tsx | 11 +++++++---- .../headers/project-archived-issues.tsx | 6 ++++-- .../headers/project-draft-issues.tsx | 4 +++- web/components/headers/project-issues.tsx | 4 +++- .../inbox/sidebar/inbox-list-item.tsx | 7 ++++--- web/components/instance/sidebar-dropdown.tsx | 5 +++-- web/components/instance/sidebar-menu.tsx | 4 +++- .../integration/single-integration-card.tsx | 5 ++++- .../issues/attachment/attachment-detail.tsx | 6 ++++-- .../actions/helpers/activity-block.tsx | 4 +++- .../activity/actions/helpers/issue-link.tsx | 5 +++-- .../issues/issue-detail/links/link-detail.tsx | 5 +++-- .../issues/issue-detail/parent-select.tsx | 7 ++++--- .../issues/issue-detail/relation-select.tsx | 7 ++++--- web/components/issues/issue-detail/sidebar.tsx | 8 +++++--- .../issue-layouts/calendar/issue-blocks.tsx | 4 +++- .../filters/header/layout-selection.tsx | 6 ++++-- .../issues/issue-layouts/gantt/blocks.tsx | 6 +++++- .../issues/issue-layouts/kanban/block.tsx | 6 ++++-- .../issues/issue-layouts/list/block.tsx | 9 +++++---- .../properties/all-properties.tsx | 8 +++++--- .../issues/issue-layouts/properties/labels.tsx | 18 +++++++++++++----- .../issue-layouts/spreadsheet/issue-row.tsx | 4 +++- web/components/issues/label.tsx | 15 ++++++++------- .../issues/parent-issues-list-modal.tsx | 5 +++-- web/components/issues/peek-overview/header.tsx | 13 +++++++------ .../issues/sub-issues/issue-list-item.tsx | 5 +++-- web/components/modules/gantt-chart/blocks.tsx | 4 ++++ web/components/modules/module-card-item.tsx | 8 +++++--- web/components/modules/module-list-item.tsx | 7 ++++--- .../notifications/notification-card.tsx | 7 ++++--- .../notifications/notification-header.tsx | 9 ++++++--- .../notifications/notification-popover.tsx | 5 ++++- web/components/pages/page-form.tsx | 5 +++-- web/components/pages/pages-list/list-item.tsx | 13 +++++++++++-- web/components/profile/sidebar.tsx | 5 +++-- web/components/project/card.tsx | 6 ++++++ web/components/project/create-project-form.tsx | 4 +++- web/components/project/member-list-item.tsx | 5 +++-- web/components/project/sidebar-list-item.tsx | 12 +++++++++++- .../states/create-update-state-inline.tsx | 4 +++- .../states/project-setting-state-list-item.tsx | 6 ++++-- web/components/ui/labels-list.tsx | 5 ++++- web/components/web-hooks/form/secret-key.tsx | 6 ++++-- web/components/workspace/help-section.tsx | 8 +++++--- .../settings/invitations-list-item.tsx | 4 +++- .../workspace/settings/members-list-item.tsx | 3 +++ web/components/workspace/sidebar-menu.tsx | 8 +++++--- web/hooks/use-platform-os.tsx | 12 ++++++++++++ .../settings-layout/profile/sidebar.tsx | 7 ++++--- 70 files changed, 327 insertions(+), 147 deletions(-) create mode 100644 web/hooks/use-platform-os.tsx diff --git a/packages/ui/src/tooltip/tooltip.tsx b/packages/ui/src/tooltip/tooltip.tsx index 65d014efe..92bab8d04 100644 --- a/packages/ui/src/tooltip/tooltip.tsx +++ b/packages/ui/src/tooltip/tooltip.tsx @@ -29,6 +29,7 @@ interface ITooltipProps { className?: string; openDelay?: number; closeDelay?: number; + isMobile?: boolean; } export const Tooltip: React.FC = ({ @@ -40,6 +41,7 @@ export const Tooltip: React.FC = ({ className = "", openDelay = 200, closeDelay, + isMobile = false, }) => ( = ({ hoverCloseDelay={closeDelay} content={
{tooltipHeading &&
{tooltipHeading}
} {tooltipContent} diff --git a/web/components/api-token/modal/generated-token-details.tsx b/web/components/api-token/modal/generated-token-details.tsx index fcae6b249..d21caf36c 100644 --- a/web/components/api-token/modal/generated-token-details.tsx +++ b/web/components/api-token/modal/generated-token-details.tsx @@ -6,6 +6,8 @@ import { renderFormattedDate } from "helpers/date-time.helper"; import { copyTextToClipboard } from "helpers/string.helper"; // types import { IApiToken } from "@plane/types"; +// hooks +import { usePlatformOS } from "hooks/use-platform-os"; type Props = { handleClose: () => void; @@ -14,7 +16,7 @@ type Props = { export const GeneratedTokenDetails: React.FC = (props) => { const { handleClose, tokenDetails } = props; - + const { isMobile } = usePlatformOS(); const copyApiToken = (token: string) => { copyTextToClipboard(token).then(() => setToast({ @@ -40,7 +42,7 @@ export const GeneratedTokenDetails: React.FC = (props) => { className="mt-4 flex w-full items-center justify-between rounded-md border-[0.5px] border-custom-border-200 px-3 py-2 text-sm font-medium outline-none" > {tokenDetails.token} - + diff --git a/web/components/api-token/token-list-item.tsx b/web/components/api-token/token-list-item.tsx index 88af9a0a2..3dd381d53 100644 --- a/web/components/api-token/token-list-item.tsx +++ b/web/components/api-token/token-list-item.tsx @@ -3,6 +3,7 @@ import { XCircle } from "lucide-react"; // components import { Tooltip } from "@plane/ui"; import { DeleteApiTokenModal } from "components/api-token"; +import { usePlatformOS } from "hooks/use-platform-os"; // ui // helpers import { renderFormattedDate, calculateTimeAgo } from "helpers/date-time.helper"; @@ -17,12 +18,14 @@ export const ApiTokenListItem: React.FC = (props) => { const { token } = props; // states const [deleteModalOpen, setDeleteModalOpen] = useState(false); + // hooks + const { isMobile } = usePlatformOS(); return ( <> setDeleteModalOpen(false)} tokenId={token.id} />
- +
{cycleDetails.assignee_ids.length > 0 && ( - +
{cycleDetails.assignee_ids.map((assigne_id) => { @@ -190,6 +193,7 @@ export const CyclesBoardCard: FC = observer((props) => {
diff --git a/web/components/cycles/cycles-view-header.tsx b/web/components/cycles/cycles-view-header.tsx index 0223fe8c3..f7ff3567c 100644 --- a/web/components/cycles/cycles-view-header.tsx +++ b/web/components/cycles/cycles-view-header.tsx @@ -5,6 +5,7 @@ import { ListFilter, Search, X } from "lucide-react"; // hooks import { useCycleFilter } from "hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { usePlatformOS } from "hooks/use-platform-os"; // components import { CycleFiltersSelection } from "components/cycles"; import { FiltersDropdown } from "components/issues"; @@ -36,6 +37,7 @@ export const CyclesViewHeader: React.FC = observer((props) => { updateFilters, updateSearchQuery, } = useCycleFilter(); + const { isMobile } = usePlatformOS(); // outside click detector hook useOutsideClickDetector(inputRef, () => { if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); @@ -134,7 +136,7 @@ export const CyclesViewHeader: React.FC = observer((props) => {
{CYCLE_VIEW_LAYOUTS.map((layout) => ( - +
- + - +
{renderFormattedDate(issue.created_at ?? "")} diff --git a/web/components/instance/sidebar-dropdown.tsx b/web/components/instance/sidebar-dropdown.tsx index 63ee1f2d1..a5a268af4 100644 --- a/web/components/instance/sidebar-dropdown.tsx +++ b/web/components/instance/sidebar-dropdown.tsx @@ -9,6 +9,7 @@ import { Menu, Transition } from "@headlessui/react"; // icons import { LogIn, LogOut, Settings, UserCog2 } from "lucide-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; import { Avatar, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; import { useApplication, useUser } from "hooks/store"; // ui @@ -34,7 +35,7 @@ export const InstanceSidebarDropdown = observer(() => { const { signOut, currentUser, currentUserSettings } = useUser(); // hooks const { setTheme } = useTheme(); - + const { isMobile } = usePlatformOS(); // redirect url for normal mode const redirectWorkspaceSlug = workspaceSlug || @@ -73,7 +74,7 @@ export const InstanceSidebarDropdown = observer(() => { {!sidebarCollapsed && (

Instance admin

- +
diff --git a/web/components/instance/sidebar-menu.tsx b/web/components/instance/sidebar-menu.tsx index 782cc90d9..e6719895f 100644 --- a/web/components/instance/sidebar-menu.tsx +++ b/web/components/instance/sidebar-menu.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { Image, BrainCog, Cog, Lock, Mail } from "lucide-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; import { Tooltip } from "@plane/ui"; import { useApplication } from "hooks/store"; // ui @@ -46,6 +47,7 @@ export const InstanceAdminSidebarMenu = () => { } = useApplication(); // router const router = useRouter(); + const { isMobile } = usePlatformOS(); return (
@@ -55,7 +57,7 @@ export const InstanceAdminSidebarMenu = () => { return (
- +
= observer(({ integration }) } = useUser(); const isUserAdmin = currentWorkspaceRole === 20; - + const { isMobile } = usePlatformOS(); const { startAuth, isConnecting: isInstalling } = useIntegrationPopup({ provider: integration.provider, github_app_name: envConfig?.github_app_name || "", @@ -129,6 +130,7 @@ export const SingleIntegrationCard: React.FC = observer(({ integration }) {workspaceIntegrations ? ( isInstalled ? ( @@ -147,6 +149,7 @@ export const SingleIntegrationCard: React.FC = observer(({ integration }) ) : ( diff --git a/web/components/issues/attachment/attachment-detail.tsx b/web/components/issues/attachment/attachment-detail.tsx index 8ff2b9305..c1be0f355 100644 --- a/web/components/issues/attachment/attachment-detail.tsx +++ b/web/components/issues/attachment/attachment-detail.tsx @@ -2,6 +2,7 @@ import { FC, useState } from "react"; import Link from "next/link"; import { AlertCircle, X } from "lucide-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // ui import { Tooltip } from "@plane/ui"; // components @@ -34,7 +35,7 @@ export const IssueAttachmentsDetail: FC = (props) => { } = useIssueDetail(); // states const [attachmentDeleteModal, setAttachmentDeleteModal] = useState(false); - + const { isMobile } = usePlatformOS(); const attachment = attachmentId && getAttachmentById(attachmentId); if (!attachment) return <>; @@ -56,10 +57,11 @@ export const IssueAttachmentsDetail: FC = (props) => {
{getFileIcon(getFileExtension(attachment.asset))}
- + {truncateText(`${getFileName(attachment.attributes.name)}`, 10)} = (pr } = useIssueDetail(); const activity = getActivityById(activityId); - + const { isMobile } = usePlatformOS(); if (!activity) return <>; return (
= (pr {children} {calculateTimeAgo(activity.created_at)} diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx index 49f813ec6..c622079e2 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/issue-link.tsx @@ -2,6 +2,7 @@ import { FC } from "react"; // hooks import { Tooltip } from "@plane/ui"; import { useIssueDetail } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // ui type TIssueLink = { @@ -14,12 +15,12 @@ export const IssueLink: FC = (props) => { const { activity: { getActivityById }, } = useIssueDetail(); - + const { isMobile } = usePlatformOS(); const activity = getActivityById(activityId); if (!activity) return <>; return ( - + = (props) => { toggleIssueLinkModalStore(modalToggle); setIsIssueLinkModalOpen(modalToggle); }; - + const { isMobile } = usePlatformOS(); const linkDetail = getLinkById(linkId); if (!linkDetail) return <>; @@ -64,7 +65,7 @@ export const IssueLinkDetail: FC = (props) => { - + {linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url} diff --git a/web/components/issues/issue-detail/parent-select.tsx b/web/components/issues/issue-detail/parent-select.tsx index 0b6501027..60cb06664 100644 --- a/web/components/issues/issue-detail/parent-select.tsx +++ b/web/components/issues/issue-detail/parent-select.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react-lite"; import Link from "next/link"; import { Pencil, X } from "lucide-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // components import { Tooltip } from "@plane/ui"; import { ParentIssuesListModal } from "components/issues"; @@ -35,7 +36,7 @@ export const IssueParentSelect: React.FC = observer((props) const parentIssue = issue?.parent_id ? getIssueById(issue.parent_id) : undefined; const parentIssueProjectDetails = parentIssue && parentIssue.project_id ? getProjectById(parentIssue.project_id) : undefined; - + const { isMobile } = usePlatformOS(); const handleParentIssue = async (_issueId: string | null = null) => { try { await issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: _issueId }); @@ -73,7 +74,7 @@ export const IssueParentSelect: React.FC = observer((props) > {issue.parent_id && parentIssue ? (
- + = observer((props) {!disabled && ( - + { e.preventDefault(); diff --git a/web/components/issues/issue-detail/relation-select.tsx b/web/components/issues/issue-detail/relation-select.tsx index 0fd0902c6..d609d1ddd 100644 --- a/web/components/issues/issue-detail/relation-select.tsx +++ b/web/components/issues/issue-detail/relation-select.tsx @@ -7,6 +7,7 @@ import { RelatedIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; import { ExistingIssuesListModal } from "components/core"; import { cn } from "helpers/common.helper"; import { useIssueDetail, useIssues, useProject } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // components // ui // helpers @@ -59,7 +60,7 @@ export const IssueRelationSelect: React.FC = observer((pro toggleRelationModal, } = useIssueDetail(); const { issueMap } = useIssues(); - + const { isMobile } = usePlatformOS(); const relationIssueIds = getRelationByIssueIdRelationType(issueId, relationKey); const onSubmit = async (data: ISearchIssueResponse[]) => { @@ -124,7 +125,7 @@ export const IssueRelationSelect: React.FC = observer((pro key={relationIssueId} className={`group flex items-center gap-1 rounded px-1.5 pb-1 pt-1 leading-3 hover:bg-custom-background-90 ${issueRelationObject[relationKey].className}`} > - + = observer((pro {!disabled && ( - + { e.preventDefault(); diff --git a/web/components/issues/issue-detail/sidebar.tsx b/web/components/issues/issue-detail/sidebar.tsx index 7f1a7a0d1..7eac649b3 100644 --- a/web/components/issues/issue-detail/sidebar.tsx +++ b/web/components/issues/issue-detail/sidebar.tsx @@ -15,6 +15,7 @@ import { CalendarCheck2, } from "lucide-react"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // components import { ArchiveIcon, @@ -78,7 +79,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { issue: { getIssueById }, } = useIssueDetail(); const { getStateById } = useProjectState(); - + const { isMobile } = usePlatformOS(); const issue = getIssueById(issueId); if (!issue) return <>; @@ -138,7 +139,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { )}
- +
diff --git a/web/components/issues/issue-layouts/filters/header/layout-selection.tsx b/web/components/issues/issue-layouts/filters/header/layout-selection.tsx index a69ead577..2b8df8edf 100644 --- a/web/components/issues/issue-layouts/filters/header/layout-selection.tsx +++ b/web/components/issues/issue-layouts/filters/header/layout-selection.tsx @@ -5,7 +5,8 @@ import { Tooltip } from "@plane/ui"; // types import { ISSUE_LAYOUTS } from "constants/issue"; import { TIssueLayouts } from "@plane/types"; -// constants +// hooks +import { usePlatformOS } from "hooks/use-platform-os"; type Props = { layouts: TIssueLayouts[]; @@ -15,11 +16,12 @@ type Props = { export const LayoutSelection: React.FC = (props) => { const { layouts, onChange, selectedLayout } = props; + const { isMobile } = usePlatformOS(); return (
{ISSUE_LAYOUTS.filter((l) => layouts.includes(l.key)).map((layout) => ( - + {isArchivingAllowed && ( )} {!disabled && ( - + diff --git a/web/components/issues/sub-issues/issue-list-item.tsx b/web/components/issues/sub-issues/issue-list-item.tsx index 5d7d19730..170bf622f 100644 --- a/web/components/issues/sub-issues/issue-list-item.tsx +++ b/web/components/issues/sub-issues/issue-list-item.tsx @@ -4,6 +4,7 @@ import { ChevronDown, ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } // components import { ControlLink, CustomMenu, Tooltip } from "@plane/ui"; import { useIssueDetail, useProject, useProjectState } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; import { TIssue } from "@plane/types"; import { IssueList } from "./issues-list"; import { IssueProperty } from "./properties"; @@ -46,7 +47,7 @@ export const IssueListItem: React.FC = observer((props) => { } = useIssueDetail(); const project = useProject(); const { getProjectStates } = useProjectState(); - + const { isMobile } = usePlatformOS(); const issue = getIssueById(issueId); const projectDetail = (issue && issue.project_id && project.getProjectById(issue.project_id)) || undefined; const currentIssueStateDetail = @@ -117,7 +118,7 @@ export const IssueListItem: React.FC = observer((props) => { onClick={() => handleIssuePeekOverview(issue)} className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" > - + {issue.name} diff --git a/web/components/modules/gantt-chart/blocks.tsx b/web/components/modules/gantt-chart/blocks.tsx index 42f22082b..60af5d048 100644 --- a/web/components/modules/gantt-chart/blocks.tsx +++ b/web/components/modules/gantt-chart/blocks.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; // hooks +import { usePlatformOS } from "hooks/use-platform-os"; // ui import { Tooltip, ModuleStatusIcon } from "@plane/ui"; // helpers @@ -25,6 +26,8 @@ export const ModuleGanttBlock: React.FC = observer((props) => { const { getModuleById } = useModule(); // derived values const moduleDetails = getModuleById(moduleId); + // hooks + const { isMobile } = usePlatformOS(); return (
= observer((props) => { >
{moduleDetails?.name}
diff --git a/web/components/modules/module-card-item.tsx b/web/components/modules/module-card-item.tsx index 4873e009c..8ef3fe024 100644 --- a/web/components/modules/module-card-item.tsx +++ b/web/components/modules/module-card-item.tsx @@ -12,6 +12,7 @@ import { EUserProjectRoles } from "constants/project"; import { renderFormattedDate } from "helpers/date-time.helper"; import { copyUrlToClipboard } from "helpers/string.helper"; import { useEventTracker, useMember, useModule, useUser } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // components // ui // helpers @@ -39,7 +40,7 @@ export const ModuleCardItem: React.FC = observer((props) => { // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - + const { isMobile } = usePlatformOS(); const handleAddToFavorites = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); @@ -179,7 +180,7 @@ export const ModuleCardItem: React.FC = observer((props) => {
- + {moduleDetails.name}
@@ -208,7 +209,7 @@ export const ModuleCardItem: React.FC = observer((props) => { {issueCount ?? "0 Issue"}
{moduleDetails.member_ids?.length > 0 && ( - +
{moduleDetails.member_ids.map((member_id) => { @@ -222,6 +223,7 @@ export const ModuleCardItem: React.FC = observer((props) => {
diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx index 7fe25b918..0b28712b0 100644 --- a/web/components/modules/module-list-item.tsx +++ b/web/components/modules/module-list-item.tsx @@ -21,6 +21,7 @@ import { EUserProjectRoles } from "constants/project"; import { renderFormattedDate } from "helpers/date-time.helper"; import { copyUrlToClipboard } from "helpers/string.helper"; import { useModule, useUser, useEventTracker, useMember } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // components // ui // helpers @@ -48,7 +49,7 @@ export const ModuleListItem: React.FC = observer((props) => { // derived values const moduleDetails = getModuleById(moduleId); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; - + const { isMobile } = usePlatformOS(); const handleAddToFavorites = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); @@ -194,7 +195,7 @@ export const ModuleListItem: React.FC = observer((props) => { )} - + {moduleDetails.name}
@@ -227,7 +228,7 @@ export const ModuleListItem: React.FC = observer((props) => {
- +
{moduleDetails.member_ids.length > 0 ? ( diff --git a/web/components/notifications/notification-card.tsx b/web/components/notifications/notification-card.tsx index 0e4904a7e..5535b4160 100644 --- a/web/components/notifications/notification-card.tsx +++ b/web/components/notifications/notification-card.tsx @@ -15,6 +15,7 @@ import { calculateTimeAgo, renderFormattedTime, renderFormattedDate } from "help import { replaceUnderscoreIfSnakeCase, truncateText, stripAndTruncateHTML } from "helpers/string.helper"; // hooks import { useEventTracker } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // type import type { IUserNotification, NotificationType } from "@plane/types"; @@ -44,7 +45,7 @@ export const NotificationCard: React.FC = (props) => { } = props; // store hooks const { captureEvent } = useEventTracker(); - + const { isMobile } = usePlatformOS(); const router = useRouter(); const { workspaceSlug } = router.query; // states @@ -358,7 +359,7 @@ export const NotificationCard: React.FC = (props) => { }, }, ].map((item) => ( - + ))} - + = (props) => } = props; // store hooks const { captureEvent } = useEventTracker(); + // hooks + const { isMobile } = usePlatformOS(); const notificationTabs: Array<{ label: string; @@ -84,7 +87,7 @@ export const NotificationHeader: React.FC = (props) =>
- + - + diff --git a/web/components/notifications/notification-popover.tsx b/web/components/notifications/notification-popover.tsx index d7aa1b07d..c3e508688 100644 --- a/web/components/notifications/notification-popover.tsx +++ b/web/components/notifications/notification-popover.tsx @@ -11,6 +11,7 @@ import { getNumberCount } from "helpers/string.helper"; import { useApplication } from "hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; import useUserNotification from "hooks/use-user-notifications"; +import { usePlatformOS } from "hooks/use-platform-os"; // components // images import emptyNotification from "public/empty-state/notification.svg"; @@ -23,6 +24,8 @@ export const NotificationPopover = observer(() => { const { theme: themeStore } = useApplication(); // refs const notificationPopoverRef = React.useRef(null); + // hooks + const { isMobile } = usePlatformOS(); const { notifications, @@ -67,7 +70,7 @@ export const NotificationPopover = observer(() => { /> <> - +
{project.assigned_issues > 0 && ( - +
= observer((props) => { const { workspaceSlug } = router.query; // store hooks const { addProjectToFavorites, removeProjectFromFavorites } = useProject(); + // hooks + const { isMobile } = usePlatformOS(); + project.member_role; // derived values const projectMembersIds = project.members?.map((member) => member.member_id); // auth @@ -171,6 +176,7 @@ export const ProjectCard: React.FC = observer((props) => {

0 ? `${project.members.length} Members` : "No Member" diff --git a/web/components/project/create-project-form.tsx b/web/components/project/create-project-form.tsx index 509cf310c..694bd8185 100644 --- a/web/components/project/create-project-form.tsx +++ b/web/components/project/create-project-form.tsx @@ -27,6 +27,7 @@ import { cn } from "helpers/common.helper"; import { projectIdentifierSanitizer } from "helpers/project.helper"; // hooks import { useEventTracker, useProject } from "hooks/store"; +import { usePlatformOS } from "hooks/use-platform-os"; // types import { IProject } from "@plane/types"; @@ -71,7 +72,7 @@ export const CreateProjectForm: FC = observer((props) => { defaultValues, reValidateMode: "onChange", }); - + const { isMobile } = usePlatformOS(); const handleAddToFavorites = (projectId: string) => { if (!workspaceSlug) return; @@ -283,6 +284,7 @@ export const CreateProjectForm: FC = observer((props) => { )} /> = observer((props) => { project: { removeMemberFromProject, getProjectMemberDetails, updateMember }, } = useMember(); const { captureEvent } = useEventTracker(); - + const { isMobile } = usePlatformOS(); // derived values const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN; const userDetails = getProjectMemberDetails(userId); @@ -171,7 +172,7 @@ export const ProjectMemberListItem: React.FC = observer((props) => { })} {(isAdmin || userDetails.member?.id === currentUser?.id) && ( - + )} - + = observer((props) => { = observer((props) => { // store hooks const { captureProjectStateEvent, setTrackElement } = useEventTracker(); const { createState, updateState } = useProjectState(); + const { isMobile } = usePlatformOS(); // form info const { handleSubmit, @@ -239,7 +241,7 @@ export const CreateUpdateStateInline: React.FC = observer((props) => { name="group" control={control} render={({ field: { value, onChange } }) => ( - +
= observer((props) => { // store hooks const { setTrackElement } = useEventTracker(); const { markStateAsDefault, moveStatePosition } = useProjectState(); + const { isMobile } = usePlatformOS(); // derived values const groupStates = statesList.filter((s) => s.group === state.group); const groupLength = groupStates.length; @@ -109,11 +111,11 @@ export const StatesListItem: React.FC = observer((props) => { disabled={state.default || groupLength === 1} > {state.default ? ( - + ) : groupLength === 1 ? ( - + ) : ( diff --git a/web/components/ui/labels-list.tsx b/web/components/ui/labels-list.tsx index fddea8478..8ebc19158 100644 --- a/web/components/ui/labels-list.tsx +++ b/web/components/ui/labels-list.tsx @@ -3,6 +3,8 @@ import { FC } from "react"; import { Tooltip } from "@plane/ui"; // types import { IIssueLabel } from "@plane/types"; +// hooks +import { usePlatformOS } from "hooks/use-platform-os"; type IssueLabelsListProps = { labels?: (IIssueLabel | undefined)[]; @@ -12,11 +14,12 @@ type IssueLabelsListProps = { export const IssueLabelsList: FC = (props) => { const { labels } = props; + const { isMobile } = usePlatformOS(); return ( <> {labels && ( <> - l?.name).join(", ")}> + l?.name).join(", ")} isMobile={isMobile}>
{`${labels.length} Labels`} diff --git a/web/components/web-hooks/form/secret-key.tsx b/web/components/web-hooks/form/secret-key.tsx index 11129fb07..0e98bc3ff 100644 --- a/web/components/web-hooks/form/secret-key.tsx +++ b/web/components/web-hooks/form/secret-key.tsx @@ -14,6 +14,8 @@ import { useWebhook, useWorkspace } from "hooks/store"; import { IWebhook } from "@plane/types"; // utils import { getCurrentHookAsCSV } from "../utils"; +// hooks +import { usePlatformOS } from "hooks/use-platform-os"; type Props = { data: Partial; @@ -30,7 +32,7 @@ export const WebhookSecretKey: FC = observer((props) => { // store hooks const { currentWorkspace } = useWorkspace(); const { currentWebhook, regenerateSecretKey, webhookSecretKey } = useWebhook(); - + const { isMobile } = usePlatformOS(); const handleCopySecretKey = () => { if (!webhookSecretKey) return; @@ -108,7 +110,7 @@ export const WebhookSecretKey: FC = observer((props) => { {webhookSecretKey && (
{SECRET_KEY_OPTIONS.map((option) => ( - + diff --git a/web/components/workspace/help-section.tsx b/web/components/workspace/help-section.tsx index 210bbbd3a..2e113414d 100644 --- a/web/components/workspace/help-section.tsx +++ b/web/components/workspace/help-section.tsx @@ -10,6 +10,7 @@ import { DiscordIcon, GithubIcon, Tooltip } from "@plane/ui"; // hooks import { useApplication } from "hooks/store"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { usePlatformOS } from "hooks/use-platform-os"; // assets import packageJson from "package.json"; @@ -41,6 +42,7 @@ export const WorkspaceHelpSection: React.FC = observe theme: { sidebarCollapsed, toggleSidebar }, commandPalette: { toggleShortcutModal }, } = useApplication(); + const { isMobile } = usePlatformOS(); // states const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); // refs @@ -69,7 +71,7 @@ export const WorkspaceHelpSection: React.FC = observe
)}
- + - + - +