From 51f795fbd76f7835eecfdcdc943314e993c1031a Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Wed, 28 Feb 2024 19:34:29 +0530 Subject: [PATCH] [WEB-477] feat: enhanced project issue filtering by cycles and modules (#3830) * feat: implemented cycle and module filter in project issues * feat: implemented cycle and module filter in draft and archived issues --- packages/types/src/view-props.d.ts | 4 + .../filters/applied-filters/cycle.tsx | 48 ++++++++++ .../filters/applied-filters/filters-list.tsx | 22 ++++- .../filters/applied-filters/index.ts | 2 + .../filters/applied-filters/module.tsx | 44 +++++++++ .../filters/header/filters/cycle.tsx | 96 +++++++++++++++++++ .../header/filters/filters-selection.tsx | 30 ++++++ .../filters/header/filters/index.ts | 2 + .../filters/header/filters/module.tsx | 89 +++++++++++++++++ .../filters/header/helpers/filter-option.tsx | 6 +- web/constants/issue.ts | 70 ++++++++++++-- web/store/issue/cycle/filter.store.ts | 2 + .../helpers/issue-filter-helper.store.ts | 4 + web/store/issue/module/filter.store.ts | 2 + 14 files changed, 411 insertions(+), 10 deletions(-) create mode 100644 web/components/issues/issue-layouts/filters/applied-filters/cycle.tsx create mode 100644 web/components/issues/issue-layouts/filters/applied-filters/module.tsx create mode 100644 web/components/issues/issue-layouts/filters/header/filters/cycle.tsx create mode 100644 web/components/issues/issue-layouts/filters/header/filters/module.tsx diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index b6454ae4cf..8e11d9cea7 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -60,6 +60,8 @@ export type TIssueParams = | "created_by" | "subscriber" | "labels" + | "cycle" + | "module" | "start_date" | "target_date" | "project" @@ -79,6 +81,8 @@ export interface IIssueFilterOptions { labels?: string[] | null; priority?: string[] | null; project?: string[] | null; + cycle?: string[] | null; + module?: string[] | null; start_date?: string[] | null; state?: string[] | null; state_group?: string[] | null; diff --git a/web/components/issues/issue-layouts/filters/applied-filters/cycle.tsx b/web/components/issues/issue-layouts/filters/applied-filters/cycle.tsx new file mode 100644 index 0000000000..6299bebd7d --- /dev/null +++ b/web/components/issues/issue-layouts/filters/applied-filters/cycle.tsx @@ -0,0 +1,48 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// hooks +import { useCycle } from "hooks/store"; +// ui +import { CycleGroupIcon } from "@plane/ui"; +// types +import { TCycleGroups } from "@plane/types"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedCycleFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + // store hooks + const { getCycleById } = useCycle(); + + return ( + <> + {values.map((cycleId) => { + const cycleDetails = getCycleById(cycleId) ?? null; + + if (!cycleDetails) return null; + + const cycleStatus = (cycleDetails?.status ? cycleDetails?.status.toLocaleLowerCase() : "draft") as TCycleGroups; + + return ( +
+ + {cycleDetails.name} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx index 4ca2538e5a..03b0c5138e 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx @@ -1,12 +1,15 @@ import { observer } from "mobx-react-lite"; import { X } from "lucide-react"; +import { useRouter } from "next/router"; // hooks -import { useUser } from "hooks/store"; +import { useApplication, useUser } from "hooks/store"; // components import { + AppliedCycleFilters, AppliedDateFilters, AppliedLabelsFilters, AppliedMembersFilters, + AppliedModuleFilters, AppliedPriorityFilters, AppliedProjectFilters, AppliedStateFilters, @@ -34,6 +37,9 @@ const dateFilters = ["start_date", "target_date"]; export const AppliedFiltersList: React.FC = observer((props) => { const { appliedFilters, handleClearAllFilters, handleRemoveFilter, labels, states, alwaysAllowEditing } = props; // store hooks + const { + router: { moduleId, cycleId }, + } = useApplication(); const { membership: { currentProjectRole }, } = useUser(); @@ -104,6 +110,20 @@ export const AppliedFiltersList: React.FC = observer((props) => { values={value} /> )} + {filterKey === "cycle" && !cycleId && ( + handleRemoveFilter("cycle", val)} + values={value} + /> + )} + {filterKey === "module" && !moduleId && ( + handleRemoveFilter("module", val)} + values={value} + /> + )} {isEditingAllowed && ( + )} + + ); + })} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/header/filters/cycle.tsx b/web/components/issues/issue-layouts/filters/header/filters/cycle.tsx new file mode 100644 index 0000000000..47b3b05068 --- /dev/null +++ b/web/components/issues/issue-layouts/filters/header/filters/cycle.tsx @@ -0,0 +1,96 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import sortBy from "lodash/sortBy"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +import { useApplication, useCycle } from "hooks/store"; +// ui +import { Loader, CycleGroupIcon } from "@plane/ui"; +// types +import { TCycleGroups } from "@plane/types"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterCycle: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + // hooks + const { + router: { projectId }, + } = useApplication(); + const { getCycleById, getProjectCycleIds } = useCycle(); + + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const cycleIds = projectId ? getProjectCycleIds(projectId) : undefined; + const cycles = cycleIds?.map((projectId) => getCycleById(projectId)!) ?? null; + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = sortBy( + cycles?.filter((cycle) => cycle.name.toLowerCase().includes(searchQuery.toLowerCase())), + (cycle) => cycle.name.toLowerCase() + ); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + const cycleStatus = (status: TCycleGroups) => (status ? status.toLocaleLowerCase() : "draft") as TCycleGroups; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((cycle) => ( + handleUpdate(cycle.id)} + icon={ + + } + title={cycle.name} + activePulse={cycleStatus(cycle?.status) === "current" ? true : false} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx b/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx index af8cfc84a5..afdee86f2c 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; import { observer } from "mobx-react-lite"; import { Search, X } from "lucide-react"; +// hooks +import { useApplication } from "hooks/store"; // components import { FilterAssignees, @@ -13,6 +15,8 @@ import { FilterState, FilterStateGroup, FilterTargetDate, + FilterCycle, + FilterModule, } from "components/issues"; // types import { IIssueFilterOptions, IIssueLabel, IState } from "@plane/types"; @@ -30,6 +34,10 @@ type Props = { export const FilterSelection: React.FC = observer((props) => { const { filters, handleFiltersUpdate, layoutDisplayFiltersOptions, labels, memberIds, states } = props; + // hooks + const { + router: { moduleId, cycleId }, + } = useApplication(); // states const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); @@ -102,6 +110,28 @@ export const FilterSelection: React.FC = observer((props) => { )} + {/* cycle */} + {isFilterEnabled("cycle") && !cycleId && ( +
+ handleFiltersUpdate("cycle", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* module */} + {isFilterEnabled("module") && !moduleId && ( +
+ handleFiltersUpdate("module", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + {/* assignees */} {isFilterEnabled("mentions") && (
diff --git a/web/components/issues/issue-layouts/filters/header/filters/index.ts b/web/components/issues/issue-layouts/filters/header/filters/index.ts index 2d3a04d0f7..ab5756bf4d 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/index.ts +++ b/web/components/issues/issue-layouts/filters/header/filters/index.ts @@ -8,4 +8,6 @@ export * from "./project"; export * from "./start-date"; export * from "./state-group"; export * from "./state"; +export * from "./cycle"; +export * from "./module"; export * from "./target-date"; diff --git a/web/components/issues/issue-layouts/filters/header/filters/module.tsx b/web/components/issues/issue-layouts/filters/header/filters/module.tsx new file mode 100644 index 0000000000..49e00f84d4 --- /dev/null +++ b/web/components/issues/issue-layouts/filters/header/filters/module.tsx @@ -0,0 +1,89 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react"; +import sortBy from "lodash/sortBy"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +import { useApplication, useModule } from "hooks/store"; +// ui +import { Loader, DiceIcon } from "@plane/ui"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterModule: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + // hooks + const { + router: { projectId }, + } = useApplication(); + const { getModuleById, getProjectModuleIds } = useModule(); + + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const moduleIds = projectId ? getProjectModuleIds(projectId) : undefined; + const modules = moduleIds?.map((projectId) => getModuleById(projectId)!) ?? null; + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = sortBy( + modules?.filter((module) => module.name.toLowerCase().includes(searchQuery.toLowerCase())), + (module) => module.name.toLowerCase() + ); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((cycle) => ( + handleUpdate(cycle.id)} + icon={} + title={cycle.name} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/header/helpers/filter-option.tsx b/web/components/issues/issue-layouts/filters/header/helpers/filter-option.tsx index f46d962eab..26c7bfaf55 100644 --- a/web/components/issues/issue-layouts/filters/header/helpers/filter-option.tsx +++ b/web/components/issues/issue-layouts/filters/header/helpers/filter-option.tsx @@ -8,10 +8,11 @@ type Props = { title: React.ReactNode; onClick?: () => void; multiple?: boolean; + activePulse?: boolean; }; export const FilterOption: React.FC = (props) => { - const { icon, isChecked, multiple = true, onClick, title } = props; + const { icon, isChecked, multiple = true, onClick, title, activePulse = false } = props; return (
+ {activePulse && ( +
+ )} ); }; diff --git a/web/constants/issue.ts b/web/constants/issue.ts index b2a8cd855e..f1bcc3a062 100644 --- a/web/constants/issue.ts +++ b/web/constants/issue.ts @@ -263,7 +263,17 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { }, archived_issues: { list: { - filters: ["priority", "state", "assignees", "created_by", "labels", "start_date", "target_date"], + filters: [ + "priority", + "state", + "cycle", + "module", + "assignees", + "created_by", + "labels", + "start_date", + "target_date", + ], display_properties: true, display_filters: { group_by: ["state", "state_detail.group", "priority", "labels", "assignees", "created_by", null], @@ -278,7 +288,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { }, draft_issues: { list: { - filters: ["priority", "state_group", "labels", "start_date", "target_date"], + filters: ["priority", "state_group", "cycle", "module", "labels", "start_date", "target_date"], display_properties: true, display_filters: { group_by: ["state_detail.group", "priority", "project", "labels", null], @@ -291,7 +301,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { }, }, kanban: { - filters: ["priority", "state_group", "labels", "start_date", "target_date"], + filters: ["priority", "state_group", "cycle", "module", "labels", "start_date", "target_date"], display_properties: true, display_filters: { group_by: ["state_detail.group", "priority", "project", "labels"], @@ -350,7 +360,18 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { }, issues: { list: { - filters: ["priority", "state", "assignees", "mentions", "created_by", "labels", "start_date", "target_date"], + filters: [ + "priority", + "state", + "cycle", + "module", + "assignees", + "mentions", + "created_by", + "labels", + "start_date", + "target_date", + ], display_properties: true, display_filters: { group_by: ["state", "priority", "labels", "assignees", "created_by", null], @@ -363,7 +384,18 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { }, }, kanban: { - filters: ["priority", "state", "assignees", "mentions", "created_by", "labels", "start_date", "target_date"], + filters: [ + "priority", + "state", + "cycle", + "module", + "assignees", + "mentions", + "created_by", + "labels", + "start_date", + "target_date", + ], display_properties: true, display_filters: { group_by: ["state", "priority", "labels", "assignees", "created_by"], @@ -377,7 +409,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { }, }, calendar: { - filters: ["priority", "state", "assignees", "mentions", "created_by", "labels", "start_date"], + filters: ["priority", "state", "cycle", "module", "assignees", "mentions", "created_by", "labels", "start_date"], display_properties: true, display_filters: { type: [null, "active", "backlog"], @@ -388,7 +420,18 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { }, }, spreadsheet: { - filters: ["priority", "state", "assignees", "mentions", "created_by", "labels", "start_date", "target_date"], + filters: [ + "priority", + "state", + "cycle", + "module", + "assignees", + "mentions", + "created_by", + "labels", + "start_date", + "target_date", + ], display_properties: true, display_filters: { order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], @@ -400,7 +443,18 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { }, }, gantt_chart: { - filters: ["priority", "state", "assignees", "mentions", "created_by", "labels", "start_date", "target_date"], + filters: [ + "priority", + "state", + "cycle", + "module", + "assignees", + "mentions", + "created_by", + "labels", + "start_date", + "target_date", + ], display_properties: false, display_filters: { order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], diff --git a/web/store/issue/cycle/filter.store.ts b/web/store/issue/cycle/filter.store.ts index b938a36d40..5d8c2a6b86 100644 --- a/web/store/issue/cycle/filter.store.ts +++ b/web/store/issue/cycle/filter.store.ts @@ -84,6 +84,8 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues"); if (!filteredParams) return undefined; + if (filteredParams.includes("cycle")) filteredParams.splice(filteredParams.indexOf("cycle"), 1); + const filteredRouteParams: Partial> = this.computedFilteredParams( userFilters?.filters as IIssueFilterOptions, userFilters?.displayFilters as IIssueDisplayFilterOptions, diff --git a/web/store/issue/helpers/issue-filter-helper.store.ts b/web/store/issue/helpers/issue-filter-helper.store.ts index 8ff45ed09b..baac4a2ade 100644 --- a/web/store/issue/helpers/issue-filter-helper.store.ts +++ b/web/store/issue/helpers/issue-filter-helper.store.ts @@ -74,6 +74,8 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { mentions: filters?.mentions || undefined, created_by: filters?.created_by || undefined, labels: filters?.labels || undefined, + cycle: filters?.cycle || undefined, + module: filters?.module || undefined, start_date: filters?.start_date || undefined, target_date: filters?.target_date || undefined, project: filters.project || undefined, @@ -107,6 +109,8 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { mentions: filters?.mentions || null, created_by: filters?.created_by || null, labels: filters?.labels || null, + cycle: filters?.cycle || null, + module: filters?.module || null, start_date: filters?.start_date || null, target_date: filters?.target_date || null, project: filters?.project || null, diff --git a/web/store/issue/module/filter.store.ts b/web/store/issue/module/filter.store.ts index f10a885a35..c353059efa 100644 --- a/web/store/issue/module/filter.store.ts +++ b/web/store/issue/module/filter.store.ts @@ -84,6 +84,8 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul const filteredParams = handleIssueQueryParamsByLayout(userFilters?.displayFilters?.layout, "issues"); if (!filteredParams) return undefined; + if (filteredParams.includes("module")) filteredParams.splice(filteredParams.indexOf("module"), 1); + const filteredRouteParams: Partial> = this.computedFilteredParams( userFilters?.filters as IIssueFilterOptions, userFilters?.displayFilters as IIssueDisplayFilterOptions,